diff --git a/.snyk b/.snyk index 8037ac1d3..8f9486fb8 100644 --- a/.snyk +++ b/.snyk @@ -162,7 +162,7 @@ ignore: 'SNYK-JS-OPENTELEMETRYCORE-17373280': - '* > @opentelemetry/core': reason: 'The fix requires migrating the Azure Functions telemetry stack from OpenTelemetry 1.x to 2.8.0 or newer. The current Azure exporter and 0.57.x SDK packages require the 1.x API family; a forced major override is type-compatible at build time but leaves mixed SDK internals. Accepted temporarily while the existing OTEL 2.x migration is completed.' - expires: '2026-07-18T00:00:00.000Z' + expires: '2026-10-31T00:00:00.000Z' created: '2026-06-18T00:00:00.000Z' 'SNYK-JS-BRACEEXPANSION-17706650': - '* > brace-expansion@1.1.13': diff --git a/apps/api/src/index.test.ts b/apps/api/src/index.test.ts index 578b9520d..e5bed5a32 100644 --- a/apps/api/src/index.test.ts +++ b/apps/api/src/index.test.ts @@ -8,6 +8,8 @@ const { startUp, initializeInfrastructureServices, registerEventHandlers, + recoverDeletedStaffRoles, + registerTimer, MockServiceApolloServer, MockServiceClientBlobStorage, MockServiceBlobStorage, @@ -66,6 +68,8 @@ const { startUp: vi.fn(), initializeInfrastructureServices: vi.fn(), registerEventHandlers: vi.fn(), + recoverDeletedStaffRoles: vi.fn(() => Promise.resolve(0)), + registerTimer: vi.fn(), MockServiceApolloServer: HoistedServiceApolloServer, MockServiceClientBlobStorage: HoistedServiceClientBlobStorage, MockServiceBlobStorage: HoistedServiceBlobStorage, @@ -85,6 +89,11 @@ const serviceRegistry = { }; vi.mock('./service-config/otel-starter.ts', () => ({})); +vi.mock('@azure/functions', () => ({ + app: { + timer: registerTimer, + }, +})); vi.mock('./cellix.ts', () => ({ Cellix: { initializeInfrastructureServices, @@ -108,6 +117,7 @@ vi.mock('@ocom/application-services', () => ({ })); vi.mock('@ocom/event-handler', () => ({ RegisterEventHandlers: registerEventHandlers, + RecoverDeletedStaffRoles: recoverDeletedStaffRoles, })); vi.mock('./service-config/mongoose/index.ts', () => ({ mongooseConnectionString: 'mongodb://example.test/cellix', @@ -254,6 +264,29 @@ describe('apps/api bootstrap', () => { apolloServerService: { service: 'apollo' }, }); expect(registerEventHandlers).toHaveBeenCalledWith({ domain: 'data-source' }); + expect(recoverDeletedStaffRoles).toHaveBeenCalledWith({ domain: 'data-source' }); + expect(registerTimer).toHaveBeenCalledWith( + 'staffRoleDeletionRecovery', + expect.objectContaining({ + schedule: '0 * * * * *', + useMonitor: true, + retry: { + strategy: 'fixedDelay', + maxRetryCount: 3, + delayInterval: 5000, + }, + }), + ); + const timerOptions = registerTimer.mock.calls[0]?.[1] as + | { + handler: () => Promise; + } + | undefined; + await timerOptions?.handler(); + expect(recoverDeletedStaffRoles).toHaveBeenCalledTimes(2); + const timerFailure = new Error('recovery failed'); + recoverDeletedStaffRoles.mockRejectedValueOnce(timerFailure); + await expect(timerOptions?.handler()).rejects.toBe(timerFailure); }); it('registers client-signing blob storage for backend use outside production', async () => { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f4a297cd4..826971702 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,8 +1,9 @@ import './service-config/otel-starter.ts'; +import { app } from '@azure/functions'; import { type ApplicationServices, buildApplicationServicesFactory } from '@ocom/application-services'; import type { ApiContextSpec } from '@ocom/context-spec'; -import { RegisterEventHandlers } from '@ocom/event-handler'; +import { RecoverDeletedStaffRoles, RegisterEventHandlers } from '@ocom/event-handler'; import { type GraphContext, graphHandlerCreator } from '@ocom/graphql-handler'; import { restHandlerCreator } from '@ocom/rest'; import { ServiceApolloServer } from '@ocom/service-apollo-server'; @@ -19,6 +20,7 @@ import * as TokenValidationConfig from './service-config/token-validation/index. const { NODE_ENV } = process.env; const isProd = NODE_ENV === 'production'; +let recoverDeletedStaffRolesForHost: (() => Promise) | undefined; Cellix.initializeInfrastructureServices((serviceRegistry) => { serviceRegistry @@ -53,6 +55,10 @@ Cellix.initializeInfrastructureServices((se const { domainDataSource } = dataSourcesFactory.withSystemPassport(); RegisterEventHandlers(domainDataSource); + recoverDeletedStaffRolesForHost = () => RecoverDeletedStaffRoles(domainDataSource); + void recoverDeletedStaffRolesForHost().catch((error: unknown) => { + console.error('Failed to recover deleted staff role events during application startup', error); + }); return { dataSourcesFactory, @@ -69,3 +75,19 @@ Cellix.initializeInfrastructureServices((se ) .registerAzureFunctionHttpHandler('rest', { route: '{communityId}/{role}/{memberId}/{*rest}' }, restHandlerCreator) .startUp(); + +app.timer('staffRoleDeletionRecovery', { + schedule: '0 * * * * *', + useMonitor: true, + retry: { + strategy: 'fixedDelay', + maxRetryCount: 3, + delayInterval: 5000, + }, + handler: async () => { + if (!recoverDeletedStaffRolesForHost) { + throw new Error('Staff role deletion recovery is unavailable before application startup completes'); + } + await recoverDeletedStaffRolesForHost(); + }, +}); diff --git a/apps/ui-staff/src/App.tsx b/apps/ui-staff/src/App.tsx index 0541f62e4..5379202af 100644 --- a/apps/ui-staff/src/App.tsx +++ b/apps/ui-staff/src/App.tsx @@ -142,7 +142,7 @@ export default function App() { } function StaffSection({ identity }: { identity: Parameters[0]['value'] }) { - const { permissions, enterpriseAppRole, user, loading } = useStaffPermissions(); + const { permissions, enterpriseAppRole, currentRoleId, user, loading } = useStaffPermissions(); if (loading) { return ( @@ -153,7 +153,7 @@ function StaffSection({ identity }: { identity: Parameters + ); diff --git a/apps/ui-staff/src/hooks/use-staff-permissions.test.ts b/apps/ui-staff/src/hooks/use-staff-permissions.test.ts new file mode 100644 index 000000000..01cab35a1 --- /dev/null +++ b/apps/ui-staff/src/hooks/use-staff-permissions.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { deriveStaffPermissions } from './use-staff-permissions.ts'; + +const makeRolePermissions = (canRemoveRole: boolean) => ({ + communityPermissions: { + canManageCommunities: false, + canManageStaffRolesAndPermissions: true, + }, + userPermissions: { + canManageUsers: false, + canAssignStaffRoles: false, + canViewStaffUsers: false, + }, + staffRolePermissions: { + canViewRoles: false, + canAddRole: false, + canEditRole: false, + canRemoveRole, + }, + financePermissions: { + canManageFinance: false, + }, + techAdminPermissions: { + canManageTechAdmin: true, + }, +}); + +describe('deriveStaffPermissions', () => { + it('does not infer role removal from broader management permissions', () => { + const permissions = deriveStaffPermissions(makeRolePermissions(false)); + + expect(permissions?.canManageStaffRolesAndPermissions).toBe(true); + expect(permissions?.canEditRole).toBe(true); + expect(permissions?.canRemoveRole).toBe(false); + }); + + it('grants role removal when the explicit permission is present', () => { + const permissions = deriveStaffPermissions(makeRolePermissions(true)); + + expect(permissions?.canRemoveRole).toBe(true); + }); +}); diff --git a/apps/ui-staff/src/hooks/use-staff-permissions.ts b/apps/ui-staff/src/hooks/use-staff-permissions.ts index 3d42e9ed0..78e02f38b 100644 --- a/apps/ui-staff/src/hooks/use-staff-permissions.ts +++ b/apps/ui-staff/src/hooks/use-staff-permissions.ts @@ -78,9 +78,33 @@ interface StaffUserQueryResult { }; } +type RolePermissions = NonNullable['permissions']>; + +export const deriveStaffPermissions = (rolePermissions: RolePermissions | undefined): StaffPermissions | undefined => { + if (!rolePermissions) { + return undefined; + } + + const isTechAdmin = rolePermissions.techAdminPermissions.canManageTechAdmin; + return { + canManageCommunities: rolePermissions.communityPermissions.canManageCommunities || isTechAdmin, + canManageStaffRolesAndPermissions: rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, + canManageUsers: rolePermissions.userPermissions.canManageUsers || isTechAdmin, + canAssignStaffRoles: rolePermissions.userPermissions.canAssignStaffRoles || isTechAdmin, + canViewStaffUsers: rolePermissions.userPermissions.canViewStaffUsers || rolePermissions.userPermissions.canManageUsers || isTechAdmin, + canManageFinance: rolePermissions.financePermissions.canManageFinance || isTechAdmin, + canManageTechAdmin: isTechAdmin, + canViewRoles: rolePermissions.staffRolePermissions.canViewRoles || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, + canAddRole: rolePermissions.staffRolePermissions.canAddRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, + canEditRole: rolePermissions.staffRolePermissions.canEditRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, + canRemoveRole: rolePermissions.staffRolePermissions.canRemoveRole, + }; +}; + export const useStaffPermissions = (): { permissions: StaffPermissions | undefined; enterpriseAppRole: string | undefined; + currentRoleId: string | undefined; user: { id?: string; displayName?: string; firstName?: string; lastName?: string; email?: string } | undefined; loading: boolean; error: Error | undefined; @@ -91,29 +115,12 @@ export const useStaffPermissions = (): { 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, - canManageStaffRolesAndPermissions: rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, - canManageUsers: rolePermissions.userPermissions.canManageUsers || isTechAdmin, - canAssignStaffRoles: rolePermissions.userPermissions.canAssignStaffRoles || isTechAdmin, - canViewStaffUsers: rolePermissions.userPermissions.canViewStaffUsers || rolePermissions.userPermissions.canManageUsers || isTechAdmin, - canManageFinance: rolePermissions.financePermissions.canManageFinance || isTechAdmin, - canManageTechAdmin: isTechAdmin, - canViewRoles: rolePermissions.staffRolePermissions.canViewRoles || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, - canAddRole: rolePermissions.staffRolePermissions.canAddRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, - canEditRole: rolePermissions.staffRolePermissions.canEditRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, - canRemoveRole: rolePermissions.staffRolePermissions.canRemoveRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, - } - : undefined; + const permissions = deriveStaffPermissions(rolePermissions); return { permissions, enterpriseAppRole: data?.currentStaffUserAndCreateIfNotExists?.role?.enterpriseAppRole, + currentRoleId: data?.currentStaffUserAndCreateIfNotExists?.role?.id, user: currentUser ? { id: currentUser.id, diff --git a/packages/cellix/event-bus-seedwork-node/src/features/node-event-bus.feature b/packages/cellix/event-bus-seedwork-node/src/features/node-event-bus.feature index 46b5ff456..e3ae0c261 100644 --- a/packages/cellix/event-bus-seedwork-node/src/features/node-event-bus.feature +++ b/packages/cellix/event-bus-seedwork-node/src/features/node-event-bus.feature @@ -35,7 +35,7 @@ Feature: NodeEventBus Then span.setStatus should be called with ERROR And recordException should be called And the span should be ended - And the error should NOT be propagated + And the error should be propagated Scenario: dispatch catch block is triggered when broadcaster throws synchronously Given the NodeEventBusInstance singleton @@ -52,23 +52,23 @@ Feature: NodeEventBus And the event is dispatched Then all handlers should be called in the order they were registered - Scenario: Multiple handlers for the same event, one throws, errors not propagated + Scenario: Multiple handlers for the same event, one throws Given multiple handlers for the same event class When all handlers are registered and one throws And the event is dispatched - Then all handlers should be called and errors are not propagated + Then all handlers should be called and the error is propagated - Scenario: Multiple handlers for the same event, all throw, errors not propagated + Scenario: Multiple handlers for the same event, all throw Given multiple handlers for the same event class When all handlers are registered and all throw And the event is dispatched - Then all handlers should be called and errors are not propagated + Then all handlers should be called and an aggregate error is propagated - Scenario: Dispatch does not wait for handler completion + Scenario: Dispatch waits for handler completion Given a handler for an event that is asynchronous When the handler is registered And the event is dispatched - Then dispatch should resolve before the handler completes + Then dispatch should resolve after the handler completes Scenario: No handlers registered for an event When the event is dispatched diff --git a/packages/cellix/event-bus-seedwork-node/src/node-event-bus.test.ts b/packages/cellix/event-bus-seedwork-node/src/node-event-bus.test.ts index 3c0b0c447..3c224f7f4 100644 --- a/packages/cellix/event-bus-seedwork-node/src/node-event-bus.test.ts +++ b/packages/cellix/event-bus-seedwork-node/src/node-event-bus.test.ts @@ -1,10 +1,11 @@ -import { CustomDomainEventImpl } from '@cellix/domain-seedwork/domain-event'; -import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; -import { expect, vi } from 'vitest'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { CustomDomainEventImpl } from '@cellix/domain-seedwork/domain-event'; +import { expect, vi } from 'vitest'; import { NodeEventBusInstance } from './node-event-bus.ts'; import type { AsyncHandlerMock } from './test-handler.types.ts'; + // --- Mocks for OpenTelemetry and performance --- const test = { for: describeFeature }; @@ -187,7 +188,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { })); }); When('the event is dispatched', async () => { - await expect(NodeEventBusInstance.dispatch(errorEvent, { test: 'fail' })).resolves.not.toThrow(); + await expect(NodeEventBusInstance.dispatch(errorEvent, { test: 'fail' })).rejects.toThrow('handler error'); }); Then('span.setStatus should be called with ERROR', () => { expect(spanMock.setStatus).toHaveBeenCalledWith(expect.objectContaining({ code: 2 })); @@ -198,8 +199,8 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { And('the span should be ended', () => { expect(spanMock.end).toHaveBeenCalled(); }); - And('the error should NOT be propagated', () => { - // Already checked by .resolves.not.toThrow() + And('the error should be propagated', () => { + // Already checked by .rejects.toThrow() }); }); @@ -248,7 +249,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }; }); When('dispatch is called for an event', async () => { - await expect(NodeEventBusInstance.dispatch(TestEvent, { test: 'fail' })).resolves.not.toThrow(); + await expect(NodeEventBusInstance.dispatch(TestEvent, { test: 'fail' })).rejects.toThrow('sync broadcast error'); }); Then('span.setStatus should be called with ERROR', () => { expect(spanMock.setStatus).toHaveBeenCalledWith(expect.objectContaining({ code: 2 })); @@ -291,7 +292,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); }); - Scenario('Multiple handlers for the same event, one throws, errors not propagated', ({ Given, When, And, Then }) => { + Scenario('Multiple handlers for the same event, one throws', ({ Given, When, And, Then }) => { Given('multiple handlers for the same event class', () => { handler1 = vi.fn(() => Promise.reject(new Error('handler1 error'))); handler2 = vi.fn(async () => undefined); @@ -301,15 +302,15 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { NodeEventBusInstance.register(TestEvent, handler2); }); And('the event is dispatched', async () => { - await expect(NodeEventBusInstance.dispatch(TestEvent, { test: 'data' })).resolves.not.toThrow(); + await expect(NodeEventBusInstance.dispatch(TestEvent, { test: 'data' })).rejects.toThrow('handler1 error'); }); - Then('all handlers should be called and errors are not propagated', () => { + Then('all handlers should be called and the error is propagated', () => { expect(handler1).toHaveBeenCalledWith({ test: 'data' }); expect(handler2).toHaveBeenCalledWith({ test: 'data' }); }); }); - Scenario('Multiple handlers for the same event, all throw, errors not propagated', ({ Given, When, And, Then }) => { + Scenario('Multiple handlers for the same event, all throw', ({ Given, When, And, Then }) => { Given('multiple handlers for the same event class', () => { handler1 = vi.fn(() => Promise.reject(new Error('handler1 error'))); handler2 = vi.fn(() => Promise.reject(new Error('handler2 error'))); @@ -319,15 +320,15 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { NodeEventBusInstance.register(TestEvent, handler2); }); And('the event is dispatched', async () => { - await expect(NodeEventBusInstance.dispatch(TestEvent, { test: 'data' })).resolves.not.toThrow(); + await expect(NodeEventBusInstance.dispatch(TestEvent, { test: 'data' })).rejects.toThrow(AggregateError); }); - Then('all handlers should be called and errors are not propagated', () => { + Then('all handlers should be called and an aggregate error is propagated', () => { expect(handler1).toHaveBeenCalledWith({ test: 'data' }); expect(handler2).toHaveBeenCalledWith({ test: 'data' }); }); }); - Scenario('Dispatch does not wait for handler completion', ({ Given, When, And, Then }) => { + Scenario('Dispatch waits for handler completion', ({ Given, When, And, Then }) => { let handlerStarted = false; let handlerCompleted = false; let asyncHandler: TestEventHandler; @@ -346,11 +347,9 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { await Promise.resolve(); // allow microtasks to run expect(handlerStarted).toBe(true); await dispatchPromise; - expect(handlerCompleted).toBe(false); - await new Promise((resolve) => setTimeout(resolve, 60)); expect(handlerCompleted).toBe(true); }); - Then('dispatch should resolve before the handler completes', () => { + Then('dispatch should resolve after the handler completes', () => { // Checked in the And step above }); }); diff --git a/packages/cellix/event-bus-seedwork-node/src/node-event-bus.ts b/packages/cellix/event-bus-seedwork-node/src/node-event-bus.ts index dccd4b618..6f8cd6bb3 100644 --- a/packages/cellix/event-bus-seedwork-node/src/node-event-bus.ts +++ b/packages/cellix/event-bus-seedwork-node/src/node-event-bus.ts @@ -1,8 +1,9 @@ -import type { EventBus } from '@cellix/domain-seedwork/event-bus'; -import type { CustomDomainEvent, DomainEvent } from '@cellix/domain-seedwork/domain-event'; import EventEmitter from 'node:events'; import { performance } from 'node:perf_hooks'; -import api, { trace, type TimeInput, SpanStatusCode } from '@opentelemetry/api'; +import type { CustomDomainEvent, DomainEvent } from '@cellix/domain-seedwork/domain-event'; +import type { EventBus } from '@cellix/domain-seedwork/event-bus'; +import api, { SpanStatusCode, type TimeInput, trace } from '@opentelemetry/api'; + // import { SEMATTRS_DB_SYSTEM, SEMATTRS_DB_NAME, SEMATTRS_DB_STATEMENT } from '@opentelemetry/semantic-conventions'; // not sure where to import these from, see link below // https://github.com/open-telemetry/opentelemetry-js/blob/main/semantic-conventions/README.md#migrated-usage @@ -18,18 +19,18 @@ class BroadCaster { } public async broadcast(event: string, data: unknown): Promise { - // Collect all listeners for the event const listeners = this.eventEmitter.listeners(event) as Array<(data: unknown) => Promise | void>; - // Execute all listeners sequentially and await each one - for (const listener of listeners) { - await listener(data); + const results = await Promise.allSettled(listeners.map(async (listener) => listener(data))); + const errors = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected').map((result) => result.reason); + if (errors.length === 1) { + throw errors[0]; + } + if (errors.length > 1) { + throw new AggregateError(errors, `Multiple handlers failed for event ${event}`); } } public on(event: string, listener: (rawPayload: unknown) => Promise | void) { - this.eventEmitter.on(event, (data) => { - // Call the listener and ignore any returned Promise - void listener(data); - }); + this.eventEmitter.on(event, listener); } public removeAllListeners() { @@ -81,6 +82,7 @@ class NodeEventBusImpl implements EventBus { } catch (err) { span.setStatus({ code: SpanStatusCode.ERROR }); span.recordException(err as Error); + throw err; } finally { span.end(); } @@ -129,6 +131,7 @@ class NodeEventBusImpl implements EventBus { }); console.error(`Error handling node event ${event.name} with data ${JSON.stringify(payload)}`); console.error(e as Error); + throw e; } finally { span.end(); } diff --git a/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/features/mongo-unit-of-work.feature b/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/features/mongo-unit-of-work.feature index 45c24a791..c3bd84176 100644 --- a/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/features/mongo-unit-of-work.feature +++ b/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/features/mongo-unit-of-work.feature @@ -23,7 +23,13 @@ Feature: MongoUnitOfWork Scenario: Integration event dispatch fails Given integration events are emitted during the domain operation When integration event dispatch fails - Then the error from dispatch is propagated and the transaction is not rolled back by the unit of work + Then the error is logged and all integration events are attempted + + Scenario: Integration event dispatch fails in propagation mode + Given integration events are emitted during the domain operation + And the unit of work propagates integration event failures + When integration event dispatch fails + Then a typed post-commit error is propagated after all integration events are attempted Scenario: Multiple integration events are emitted and all succeed Given integration events are emitted during the domain operation diff --git a/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/index.ts b/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/index.ts index b85689444..d5b776cbb 100644 --- a/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/index.ts +++ b/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/index.ts @@ -2,7 +2,7 @@ export { ObjectId } from 'mongodb'; export { type Base, type NestedPath, NestedPathOptions, type SubdocumentBase } from './base.ts'; export { type MongooseContextFactory, modelFactory } from './mongo-connection.ts'; export { MongooseDomainAdapter } from './mongo-domain-adapter.ts'; -export { MongoosePropArray } from './mongoose-prop-array.ts'; export { MongoRepositoryBase } from './mongo-repository.ts'; export { MongoTypeConverter } from './mongo-type-converter.ts'; -export { getInitializedUnitOfWork, MongoUnitOfWork } from './mongo-unit-of-work.ts'; +export { getInitializedUnitOfWork, MongoUnitOfWork, type MongoUnitOfWorkOptions, PostCommitEventError } from './mongo-unit-of-work.ts'; +export { MongoosePropArray } from './mongoose-prop-array.ts'; diff --git a/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-repository.test.ts b/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-repository.test.ts index 195638f87..b5136e587 100644 --- a/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-repository.test.ts +++ b/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-repository.test.ts @@ -1,15 +1,15 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import { AggregateRoot } from '@cellix/domain-seedwork/aggregate-root'; import { CustomDomainEventImpl } from '@cellix/domain-seedwork/domain-event'; -import type { TypeConverter } from '@cellix/domain-seedwork/type-converter'; import type { EventBus } from '@cellix/domain-seedwork/event-bus'; import { NotFoundError } from '@cellix/domain-seedwork/repository'; -import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { TypeConverter } from '@cellix/domain-seedwork/type-converter'; import type mongoose from 'mongoose'; import { expect, type Mock, vi } from 'vitest'; import type { Base } from './base.ts'; import { MongoRepositoryBase } from './mongo-repository.ts'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; // Minimal Base (MongoType) diff --git a/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-repository.ts b/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-repository.ts index e43d0ce8a..3801e52bf 100644 --- a/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-repository.ts +++ b/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-repository.ts @@ -1,10 +1,10 @@ -import type { DomainEntityProps } from '@cellix/domain-seedwork/domain-entity'; import type { AggregateRoot } from '@cellix/domain-seedwork/aggregate-root'; -import { NotFoundError } from '@cellix/domain-seedwork/repository'; +import type { DomainEntityProps } from '@cellix/domain-seedwork/domain-entity'; +import type { CustomDomainEvent } from '@cellix/domain-seedwork/domain-event'; +import type { EventBus } from '@cellix/domain-seedwork/event-bus'; import type { Repository } from '@cellix/domain-seedwork/repository'; +import { NotFoundError } from '@cellix/domain-seedwork/repository'; import type { TypeConverter } from '@cellix/domain-seedwork/type-converter'; -import type { EventBus } from '@cellix/domain-seedwork/event-bus'; -import type { CustomDomainEvent } from '@cellix/domain-seedwork/domain-event'; import type { ClientSession, Model } from 'mongoose'; import type { Base } from './base.ts'; diff --git a/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-unit-of-work.test.ts b/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-unit-of-work.test.ts index 54ec80f6f..952b20642 100644 --- a/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-unit-of-work.test.ts +++ b/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-unit-of-work.test.ts @@ -1,19 +1,20 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import { AggregateRoot } from '@cellix/domain-seedwork/aggregate-root'; import type { DomainEntityProps } from '@cellix/domain-seedwork/domain-entity'; import { CustomDomainEventImpl } from '@cellix/domain-seedwork/domain-event'; import type { EventBus } from '@cellix/domain-seedwork/event-bus'; import type { TypeConverter } from '@cellix/domain-seedwork/type-converter'; import type { InitializedUnitOfWork } from '@cellix/domain-seedwork/unit-of-work'; -import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; -import { expect, vi, type Mock } from 'vitest'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import type { ClientSession, Model } from 'mongoose'; import mongoose from 'mongoose'; +import { expect, type Mock, vi } from 'vitest'; import type { Base } from './index.ts'; -import { MongoUnitOfWork, getInitializedUnitOfWork } from './mongo-unit-of-work.ts'; import { MongoRepositoryBase } from './mongo-repository.ts'; +import { getInitializedUnitOfWork, MongoUnitOfWork, PostCommitEventError } from './mongo-unit-of-work.ts'; // Type alias for test purposes to avoid linting issues @@ -187,19 +188,56 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { Scenario('Integration event dispatch fails', ({ Given, When, Then }) => { let event1: TestEvent; let event2: TestEvent; + let consoleErrorSpy: ReturnType; Given('integration events are emitted during the domain operation', () => { event1 = new TestEvent('id'); event1.payload = { foo: 'bar1' }; event2 = new TestEvent('id'); event2.payload = { foo: 'bar2' }; repoInstance.getIntegrationEvents = vi.fn(() => [event1, event2]); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); }); When('integration event dispatch fails', async () => { (integrationEventBus.dispatch as Mock).mockRejectedValueOnce(new Error('fail1')).mockResolvedValueOnce(undefined); - await expect(unitOfWork.withTransaction(Passport, domainOperation)).rejects.toThrow('fail1'); + await unitOfWork.withTransaction(Passport, domainOperation); + }); + Then('the error is logged and all integration events are attempted', () => { + expect(integrationEventBus.dispatch).toHaveBeenCalledTimes(2); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('failed after the transaction committed'), expect.any(Error)); + consoleErrorSpy.mockRestore(); + }); + }); + + Scenario('Integration event dispatch fails in propagation mode', ({ Given, And, When, Then }) => { + let event1: TestEvent; + let event2: TestEvent; + let thrownError: unknown; + Given('integration events are emitted during the domain operation', () => { + event1 = new TestEvent('id'); + event1.payload = { foo: 'bar1' }; + event2 = new TestEvent('id'); + event2.payload = { foo: 'bar2' }; + repoInstance.getIntegrationEvents = vi.fn(() => [event1, event2]); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + And('the unit of work propagates integration event failures', () => { + unitOfWork = new MongoUnitOfWork(eventBus, integrationEventBus, mockModel, typeConverter, mockRepoClass, { + integrationEventErrors: 'propagate', + }); }); - Then('the error from dispatch is propagated and the transaction is not rolled back by the unit of work', () => { - expect(integrationEventBus.dispatch).toHaveBeenCalledTimes(1); + When('integration event dispatch fails', async () => { + (integrationEventBus.dispatch as Mock).mockRejectedValueOnce(new Error('fail1')).mockResolvedValueOnce(undefined); + try { + await unitOfWork.withTransaction(Passport, domainOperation); + } catch (error) { + thrownError = error; + } + }); + Then('a typed post-commit error is propagated after all integration events are attempted', () => { + expect(thrownError).toBeInstanceOf(PostCommitEventError); + expect((thrownError as PostCommitEventError).eventName).toBe(TestEvent.name); + expect((thrownError as PostCommitEventError).cause).toEqual(new Error('fail1')); + expect(integrationEventBus.dispatch).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-unit-of-work.ts b/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-unit-of-work.ts index 33833db44..4431a2b27 100644 --- a/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-unit-of-work.ts +++ b/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-unit-of-work.ts @@ -1,12 +1,28 @@ -import type { DomainEntityProps } from '@cellix/domain-seedwork/domain-entity'; import type { AggregateRoot } from '@cellix/domain-seedwork/aggregate-root'; -import type { InitializedUnitOfWork, UnitOfWork } from '@cellix/domain-seedwork/unit-of-work'; -import type { TypeConverter } from '@cellix/domain-seedwork/type-converter'; -import type { EventBus } from '@cellix/domain-seedwork/event-bus'; +import type { DomainEntityProps } from '@cellix/domain-seedwork/domain-entity'; import type { CustomDomainEvent } from '@cellix/domain-seedwork/domain-event'; +import type { EventBus } from '@cellix/domain-seedwork/event-bus'; +import type { TypeConverter } from '@cellix/domain-seedwork/type-converter'; +import type { InitializedUnitOfWork, UnitOfWork } from '@cellix/domain-seedwork/unit-of-work'; import mongoose, { type ClientSession, type Model } from 'mongoose'; -import { MongoRepositoryBase } from './mongo-repository.ts'; import type { Base } from './base.ts'; +import { MongoRepositoryBase } from './mongo-repository.ts'; + +export interface MongoUnitOfWorkOptions { + integrationEventErrors?: 'log' | 'propagate'; +} + +export class PostCommitEventError extends Error { + public readonly eventName: string; + public readonly eventPayload: unknown; + + constructor(event: CustomDomainEvent, cause: unknown) { + super(`Integration event ${event.constructor.name} failed after the transaction committed`, { cause }); + this.name = 'PostCommitEventError'; + this.eventName = event.constructor.name; + this.eventPayload = event.payload; + } +} export class MongoUnitOfWork< MongoType extends Base, @@ -20,6 +36,7 @@ export class MongoUnitOfWork< public readonly typeConverter: TypeConverter; public readonly bus: EventBus; public readonly integrationEventBus: EventBus; + public readonly options: Required; // protected passport: PassportType; public readonly repoClass: new ( passport: PassportType, @@ -36,6 +53,7 @@ export class MongoUnitOfWork< model: Model, typeConverter: TypeConverter, repoClass: new (passport: PassportType, model: Model, typeConverter: TypeConverter, bus: EventBus, session: ClientSession) => RepoType, + options: MongoUnitOfWorkOptions = {}, ) { // this.passport = passport; this.model = model; @@ -43,6 +61,9 @@ export class MongoUnitOfWork< this.bus = bus; this.integrationEventBus = integrationEventBus; this.repoClass = repoClass; + this.options = { + integrationEventErrors: options.integrationEventErrors ?? 'log', + }; } async withTransaction(passport: PassportType, func: (repository: RepoType) => Promise): Promise { @@ -64,9 +85,24 @@ export class MongoUnitOfWork< }); console.log(`${repoEvents.length} integration events`); //Send integration events after transaction is completed + const postCommitErrors: PostCommitEventError[] = []; for (const event of repoEvents) { - await this.integrationEventBus.dispatch(event.constructor as new (aggregateId: string) => typeof event, event.payload); - console.log(`dispatch integration event ${event.constructor.name} with payload ${JSON.stringify(event.payload)}`); + try { + await this.integrationEventBus.dispatch(event.constructor as new (aggregateId: string) => typeof event, event.payload); + console.log(`dispatch integration event ${event.constructor.name} with payload ${JSON.stringify(event.payload)}`); + } catch (error) { + const postCommitError = new PostCommitEventError(event, error); + console.error(postCommitError.message, error); + if (this.options.integrationEventErrors === 'propagate') { + postCommitErrors.push(postCommitError); + } + } + } + if (postCommitErrors.length === 1) { + throw postCommitErrors[0]; + } + if (postCommitErrors.length > 1) { + throw new AggregateError(postCommitErrors, 'Multiple integration events failed after the transaction committed'); } } } diff --git a/packages/ocom-verification/acceptance-api/package.json b/packages/ocom-verification/acceptance-api/package.json index 06e434cb3..4690c16b1 100644 --- a/packages/ocom-verification/acceptance-api/package.json +++ b/packages/ocom-verification/acceptance-api/package.json @@ -30,6 +30,7 @@ "@ocom/application-services": "workspace:*", "@ocom/context-spec": "workspace:*", "@ocom/data-sources-mongoose-models": "workspace:*", + "@ocom/event-handler": "workspace:*", "@ocom/graphql": "workspace:*", "@ocom/persistence": "workspace:*", "@ocom/service-apollo-server": "workspace:*", diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts index 61dd39c63..506cb8aea 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts @@ -11,6 +11,7 @@ import { StaffUserNamed } from '../questions/staff-user-named.ts'; import { ViewedStaffRole } from '../questions/viewed-staff-role.ts'; import { AssignStaffRoleToUser } from '../tasks/assign-staff-role-to-user.ts'; import { CreateStaffRole } from '../tasks/create-staff-role.ts'; +import { DeleteStaffRole } from '../tasks/delete-staff-role.ts'; import { EnsureStaffRoleExists } from '../tasks/ensure-staff-role-exists.ts'; import { GrantStaffRolePermission } from '../tasks/grant-staff-role-permission.ts'; import { RenameStaffRole } from '../tasks/rename-staff-role.ts'; @@ -92,6 +93,20 @@ When('{word} assigns the staff role {string} to the staff user {string}', async await actorCalled(actorName).attemptsTo(AssignStaffRoleToUser.assign(roleName).to(staffUserDisplayName)); }); +When('{word} deletes the staff role {string}', async (actorName: string, roleName: string) => { + await actorCalled(actorName).attemptsTo(DeleteStaffRole.named(roleName)); +}); + +When('{word} attempts to delete the staff role {string}', async (actorName: string, roleName: string) => { + const actor = actorCalled(actorName); + await clearStaffRoleOutcomeNotes(actor); + try { + await actor.attemptsTo(DeleteStaffRole.named(roleName)); + } catch (error) { + await actor.attemptsTo(notes().set('lastStaffRoleError', errorMessageOf(error))); + } +}); + Then('the staff roles list should include the default staff roles', async () => { const listed = await actorInTheSpotlight().answer(ListedStaffRoleNames.recorded()); if (!listed) { @@ -141,6 +156,22 @@ Then('the staff role should be updated successfully', async () => { } }); +Then('the staff role should be deleted successfully', async () => { + const status = await actorInTheSpotlight().answer(StaffRoleStatus.of()); + if (status !== 'SUCCESS') { + const capturedError = await actorInTheSpotlight().answer(StaffRoleError.captured()); + throw new Error(`Expected staff role deletion to succeed, but it failed: ${capturedError ?? 'no mutation was performed'}`); + } +}); + +Then('the staff roles list should not include {string}', async (roleName: string) => { + const roles = await actorInTheSpotlight().answer(StaffRolesList.displayed()); + const names = roles.map((role) => role.roleName); + if (names.includes(roleName)) { + throw new Error(`Expected staff roles list to no longer include "${roleName}", but got: [${names.join(', ')}]`); + } +}); + Then('the staff role {string} should have the permission {string} granted', async (roleName: string, permissionKey: string) => { const granted = await actorInTheSpotlight().answer(StaffRolePermission.granted(roleName, permissionKey)); if (!granted) { @@ -149,10 +180,19 @@ Then('the staff role {string} should have the permission {string} granted', asyn }); Then('the staff user {string} should have the staff role {string}', async (staffUserDisplayName: string, roleName: string) => { - const staffUser = await actorInTheSpotlight().answer(StaffUserNamed.called(staffUserDisplayName)); - if (staffUser.role?.roleName !== roleName) { - throw new Error(`Expected staff user "${staffUserDisplayName}" to have role "${roleName}", but got "${staffUser.role?.roleName ?? 'none'}"`); - } + // Integration event handlers run fire-and-forget after the mutation commits, + // so reassignment may complete shortly after the delete response. Poll briefly. + const deadline = Date.now() + 5_000; + let lastSeenRoleName: string | undefined; + while (Date.now() < deadline) { + const staffUser = await actorInTheSpotlight().answer(StaffUserNamed.called(staffUserDisplayName)); + lastSeenRoleName = staffUser.role?.roleName; + if (lastSeenRoleName === roleName) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Expected staff user "${staffUserDisplayName}" to have role "${roleName}", but got "${lastSeenRoleName ?? 'none'}"`); }); Then('she should see a staff role error containing {string}', async (expectedFragment: string) => { diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/delete-staff-role.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/delete-staff-role.ts new file mode 100644 index 000000000..da552ecff --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/delete-staff-role.ts @@ -0,0 +1,31 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { DeleteStaffRole as DeleteStaffRoleAbility } from '../../../shared/abilities/delete-staff-role.ts'; +import type { StaffRoleNotes } from '../notes/staff-role-notes.ts'; +import { StaffRoleNamed } from '../questions/staff-role-named.ts'; + +/** + * Task that deletes an existing staff role through the API and records the + * outcome in actor notes. + */ +export class DeleteStaffRole extends Task { + static named(roleName: string): DeleteStaffRole { + return new DeleteStaffRole(roleName); + } + + private constructor(private readonly roleName: string) { + super(`deletes the staff role "${roleName}"`); + } + + async performAs(actor: Actor): Promise { + const role = await actor.answer(StaffRoleNamed.called(this.roleName)); + if (!role) { + throw new Error(`Staff role "${this.roleName}" was not found`); + } + + await DeleteStaffRoleAbility.as(actor).performAs(actor, role.id); + + await actor.attemptsTo(notes().set('lastStaffRoleId', role.id), notes().set('lastStaffRoleName', role.roleName), notes().set('lastStaffRoleStatus', 'SUCCESS')); + } + + override toString = () => `deletes the staff role "${this.roleName}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts index 3a2fedc8b..b2a2a47d2 100644 --- a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts +++ b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts @@ -1,6 +1,7 @@ import type { BlobUploadAuthorizationHeader, BlobUploadCommonResponse, CreateBlobAuthorizationHeaderRequest } from '@cellix/service-blob-storage'; import { type ApplicationServicesFactory, buildApplicationServicesFactory } from '@ocom/application-services'; import type { ApiContextSpec } from '@ocom/context-spec'; +import { RegisterEventHandlers } from '@ocom/event-handler'; import { Persistence } from '@ocom/persistence'; import type { ServiceApolloServer } from '@ocom/service-apollo-server'; import type { BlobAddress, BlobStorageOperations, ClientUploadOperations, ListBlobsRequest, UploadTextBlobRequest } from '@ocom/service-blob-storage'; @@ -21,6 +22,19 @@ type EndUserUpdateQueueMessage = Awaited): void { + if (eventHandlersRegistered) { + return; + } + const { domainDataSource } = dataSourcesFactory.withSystemPassport(); + RegisterEventHandlers(domainDataSource); + eventHandlersRegistered = true; +} + function createMockTokenValidation(): TokenValidation { return { verifyJwt: (token: string): Promise | null> => { @@ -136,6 +150,7 @@ function createRecordingQueueStorageService(): QueueStorageOperations { export function createMockApplicationServicesFactory(serviceMongoose: ServiceMongoose): ApplicationServicesFactory { const dataSourcesFactory = Persistence(serviceMongoose); + registerEventHandlersOnce(dataSourcesFactory); const blobStorageService = createNoOpBlobStorageService(); const clientOperationsService = createNoOpClientOperationsService(); const queueStorageService = createRecordingQueueStorageService(); diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/delete-staff-role.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/delete-staff-role.ts new file mode 100644 index 000000000..666ea69ec --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/delete-staff-role.ts @@ -0,0 +1,38 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { Ability, type Actor } from '@serenity-js/core'; +import { type MutationStatus, STAFF_ROLE_DELETE_MUTATION } from '../graphql/staff-role-operations.ts'; + +/** Handler that performs a staff role deletion for an API actor. */ +type DeleteStaffRoleHandler = (actor: Actor, roleId: string) => Promise; + +/** + * Serenity ability that deletes staff roles through the API verification server. + * Throws when the mutation reports a validation or authorization failure. + */ +export class DeleteStaffRole extends Ability { + constructor(private readonly handler: DeleteStaffRoleHandler) { + super(); + } + + static using(handler: DeleteStaffRoleHandler): DeleteStaffRole { + return new DeleteStaffRole(handler); + } + + async performAs(actor: Actor, roleId: string): Promise { + await this.handler(actor, roleId); + } +} + +export function deleteStaffRoleAbility(): DeleteStaffRole { + return DeleteStaffRole.using(async (actor, roleId) => { + const graphql = GraphQLClient.as(actor); + const response = await graphql.execute(STAFF_ROLE_DELETE_MUTATION, { + input: { id: roleId }, + }); + + const mutationResult = response.data['staffRoleDelete'] as { status: MutationStatus } | undefined; + if (mutationResult?.status?.success !== true) { + throw new Error(String(mutationResult?.status?.errorMessage ?? 'Failed to delete staff role')); + } + }); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts index 41d8d8913..15cfbe011 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts @@ -1,5 +1,6 @@ export { assignStaffRoleAbility } from './assign-staff-role.ts'; export { createCommunityAbility } from './create-community.ts'; export { createStaffRoleAbility } from './create-staff-role.ts'; +export { deleteStaffRoleAbility } from './delete-staff-role.ts'; export { createGraphQLClientAbility } from './graphql-client.ts'; export { updateStaffRoleAbility } from './update-staff-role.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/shared/graphql/staff-role-operations.ts b/packages/ocom-verification/acceptance-api/src/shared/graphql/staff-role-operations.ts index 38e66c807..974c95c95 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/graphql/staff-role-operations.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/graphql/staff-role-operations.ts @@ -150,6 +150,17 @@ export const STAFF_ROLE_UPDATE_MUTATION = ` } `; +export const STAFF_ROLE_DELETE_MUTATION = ` + mutation StaffRoleDelete($input: StaffRoleDeleteInput!) { + staffRoleDelete(input: $input) { + status { + success + errorMessage + } + } + } +`; + export const STAFF_USERS_QUERY = ` query StaffUsers { staffUsers { diff --git a/packages/ocom-verification/acceptance-api/src/world.ts b/packages/ocom-verification/acceptance-api/src/world.ts index f2a1db5e4..1be5b9df4 100644 --- a/packages/ocom-verification/acceptance-api/src/world.ts +++ b/packages/ocom-verification/acceptance-api/src/world.ts @@ -6,6 +6,7 @@ import { infrastructure } from './infrastructure.ts'; import { assignStaffRoleAbility } from './shared/abilities/assign-staff-role.ts'; import { createCommunityAbility } from './shared/abilities/create-community.ts'; import { createStaffRoleAbility } from './shared/abilities/create-staff-role.ts'; +import { deleteStaffRoleAbility } from './shared/abilities/delete-staff-role.ts'; import { createGraphQLClientAbility } from './shared/abilities/graphql-client.ts'; import { updateStaffRoleAbility } from './shared/abilities/update-staff-role.ts'; @@ -19,7 +20,14 @@ export const CellixApiWorld = registerManagedSerenityWorld({ createCast: (state) => new SerenityCast({ useNotepad: true, - abilities: [(actor) => createGraphQLClientAbility(graphqlUrl(state), actor.name), () => createCommunityAbility(), () => createStaffRoleAbility(), () => updateStaffRoleAbility(), () => assignStaffRoleAbility()], + abilities: [ + (actor) => createGraphQLClientAbility(graphqlUrl(state), actor.name), + () => createCommunityAbility(), + () => createStaffRoleAbility(), + () => updateStaffRoleAbility(), + () => deleteStaffRoleAbility(), + () => assignStaffRoleAbility(), + ], }), }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/abilities/mock-staff-role-backend.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/abilities/mock-staff-role-backend.ts index 5fd6205b3..d67c66c72 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/abilities/mock-staff-role-backend.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/abilities/mock-staff-role-backend.ts @@ -1,8 +1,14 @@ import type { MockedResponse } from '@apollo/client/testing'; import { DEFAULT_STAFF_ROLE_NAMES } from '@ocom-verification/verification-shared/test-data'; +import * as GeneratedStaffRoleDocuments from '../../../../../../ocom/ui-staff-route-user-management/src/generated.tsx'; import { StaffRoleByIdDocument, StaffRoleCreateDocument, StaffRolesListDocument, StaffRoleUpdateDocument } from '../../../../../../ocom/ui-staff-route-user-management/src/generated.tsx'; import type { StaffAuth } from '../../../../../../ocom/ui-staff-shared/src/staff-route-shell.tsx'; +// The delete document only exists once the staff-role delete mutation has been +// implemented and code-generated; resolve it dynamically so the remaining +// staff-role scenarios keep running while the delete flow is red. +const StaffRoleDeleteDocument = (GeneratedStaffRoleDocuments as Record)['StaffRoleDeleteDocument'] as typeof StaffRoleByIdDocument | undefined; + /** Business roles supported by the staff portal UI acceptance flows. */ type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; @@ -53,6 +59,7 @@ interface MockStaffRole { id: string; roleName: string; enterpriseAppRole: string; + isDefault: boolean; createdAt: string; updatedAt: string; permissions: MockPermissions; @@ -134,15 +141,17 @@ let currentCustomAuth: StaffAuth | undefined; let lastMutation: MockMutationResult | undefined; let currentPath = '/'; let idCounter = 0; +// When true, the mocked delete mutation reports a failure instead of deleting. +let deleteShouldFail = false; const nextRoleId = (): string => { idCounter += 1; return idCounter.toString(16).padStart(24, '0'); }; -const addRole = (roleName: string, enterpriseAppRole: string): MockStaffRole => { +const addRole = (roleName: string, enterpriseAppRole: string, isDefault = false): MockStaffRole => { const now = new Date().toISOString(); - const role: MockStaffRole = { id: nextRoleId(), roleName, enterpriseAppRole, createdAt: now, updatedAt: now, permissions: emptyPermissions() }; + const role: MockStaffRole = { id: nextRoleId(), roleName, enterpriseAppRole, isDefault, createdAt: now, updatedAt: now, permissions: emptyPermissions() }; uiRoles.push(role); return role; }; @@ -158,16 +167,31 @@ export function resetStaffRoleUiState(businessRole: StaffBusinessRole): void { currentPath = '/'; currentAuthRole = businessRole; currentCustomAuth = undefined; + deleteShouldFail = false; for (const roleName of DEFAULT_STAFF_ROLE_NAMES) { - addRole(roleName, `Staff.${roleName.replace('Default ', '').replaceAll(' ', '')}`); + addRole(roleName, `Staff.${roleName.replace('Default ', '').replaceAll(' ', '')}`, true); } } +/** Makes the mocked staff-role delete mutation fail for the rest of the scenario. */ +export function failStaffRoleDeletions(): void { + deleteShouldFail = true; +} + /** Overrides the business-role auth with a custom fine-grained StaffAuth. */ export function setScopedStaffAuth(staffAuth: StaffAuth): void { currentCustomAuth = staffAuth; } +/** Sets the current authenticated staff user's role to an existing mocked role. */ +export function assignMockStaffRoleToCurrentAuth(roleName: string): void { + const role = uiRoles.find((candidate) => candidate.roleName === roleName); + if (!role) { + throw new Error(`Mock staff role "${roleName}" was not found`); + } + currentCustomAuth = { ...currentStaffAuth(), currentRoleId: role.id }; +} + /** The StaffAuth the rendered staff-role screens should observe. */ export function currentStaffAuth(): StaffAuth { return currentCustomAuth ?? staffAuthByRole[currentAuthRole]; @@ -219,6 +243,7 @@ const toEditFields = (role: MockStaffRole) => ({ id: role.id, roleName: role.roleName, enterpriseAppRole: role.enterpriseAppRole, + isDefault: role.isDefault, permissions: role.permissions, }); @@ -308,4 +333,37 @@ export const buildStaffRoleMocks = (): MockedResponse[] => [ }; }, }, + ...(StaffRoleDeleteDocument + ? [ + { + request: { query: StaffRoleDeleteDocument }, + variableMatcher: () => true, + maxUsageCount: Number.POSITIVE_INFINITY, + result: (variables: { input?: StaffRoleMutationInput }) => { + const role = uiRoles.find((candidate) => candidate.id === variables.input?.id); + if (deleteShouldFail || !role) { + lastMutation = { success: false, errorMessage: deleteShouldFail ? 'Deletion rejected by the server' : 'Staff role not found' }; + return { + data: { + staffRoleDelete: { + __typename: 'StaffRoleDeleteMutationResult' as const, + status: { __typename: 'MutationStatus' as const, success: false, errorMessage: lastMutation.errorMessage }, + }, + }, + }; + } + uiRoles = uiRoles.filter((candidate) => candidate.id !== role.id); + lastMutation = { success: true }; + return { + data: { + staffRoleDelete: { + __typename: 'StaffRoleDeleteMutationResult' as const, + status: { __typename: 'MutationStatus' as const, success: true, errorMessage: null }, + }, + }, + }; + }, + } satisfies MockedResponse, + ] + : []), ]; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-screen.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-screen.ts index 2f62fa0a0..b8111ffb5 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-screen.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-screen.ts @@ -1,4 +1,5 @@ import { Question } from '@serenity-js/core'; +import { currentMockPath } from '../abilities/mock-staff-role-backend.ts'; import { feedbackPage, formPageFor, listPageFor, waitUntilUi } from '../tasks/staff-roles-screen.ts'; /** Question that reads the staff role names visible in the rendered list. */ @@ -74,3 +75,38 @@ export const CreateRoleActionVisible = () => Question.about('whether the create /** Question that answers whether any staff role edit action is visible. */ export const AnyEditActionVisible = () => Question.about('whether any staff role edit action is visible', async (actor) => await listPageFor(actor).hasAnyEditAction()); + +/** Question that answers whether the delete action is visible on the details screen. */ +export const DeleteActionVisible = () => Question.about('whether the staff role delete action is visible', async (actor) => await formPageFor(actor).hasDeleteAction()); + +/** Question that reads the text of the currently open delete confirmation. */ +export const DeleteConfirmationText = () => + Question.about('the staff role delete confirmation text', async (actor) => { + const formPage = formPageFor(actor); + await waitUntilUi(() => formPage.deleteConfirmation.isVisible(), 'Expected the delete confirmation to be visible'); + return await formPage.deleteConfirmationText(); + }); + +/** Question that waits for navigation back to the rendered staff-role list. */ +export const StaffRolesListVisible = () => + Question.about('whether the staff roles list route and heading are visible', async (actor) => { + const listPage = listPageFor(actor); + try { + await waitUntilUi(async () => currentMockPath() === '/' && (await listPage.heading.isVisible()), 'Expected navigation back to the staff roles list'); + return true; + } catch { + return false; + } + }); + +/** Question that waits for a staff role to disappear from the rendered list. */ +export const StaffRolesListExcludes = (roleName: string) => + Question.about(`whether the staff roles list no longer includes "${roleName}"`, async (actor) => { + const listPage = listPageFor(actor); + try { + await waitUntilUi(async () => currentMockPath() === '/' && (await listPage.heading.isVisible()) && !(await listPage.hasRoleNamed(roleName)), `Expected the staff roles list to be visible and no longer include "${roleName}"`); + return true; + } catch { + return false; + } + }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx index 14744c8a1..7282c552d 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx @@ -2,20 +2,25 @@ import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-da import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; import { DEFAULT_STAFF_ROLE_NAMES } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, actorInTheSpotlight } from '@serenity-js/core'; -import { ensureMockStaffRole, resetStaffRoleUiState, setScopedStaffAuth } from '../abilities/mock-staff-role-backend.ts'; +import { assignMockStaffRoleToCurrentAuth, ensureMockStaffRole, failStaffRoleDeletions, resetStaffRoleUiState, setScopedStaffAuth } from '../abilities/mock-staff-role-backend.ts'; import { BaselineStaffRoleCount, LastStaffRoleMutation, MockedStaffRoleCount } from '../questions/staff-role-outcome.ts'; import { AnyEditActionVisible, CreateRoleActionVisible, + DeleteActionVisible, + DeleteConfirmationText, ErrorFeedbackContaining, FormEnterpriseAppRole, FormRoleNameValue, ListedStaffRoleNames, + StaffRolesListExcludes, StaffRolesListIncludes, + StaffRolesListVisible, SuccessFeedbackVisible, ValidationErrorMatching, } from '../questions/staff-role-screen.ts'; import { CreateStaffRoleViaForm, RenameStaffRoleViaForm, type StaffRoleFormInput } from '../tasks/create-staff-role.ts'; +import { CancelStaffRoleDeletion, DeleteStaffRoleViaForm, StartDeletingStaffRole } from '../tasks/delete-staff-role.ts'; import { OpenStaffRoleEditScreenRecordingRoute, OpenStaffRolesScreenRecordingRoute } from '../tasks/open-staff-role-screens.ts'; import { OpenEditFormForRole, OpenStaffRolesList } from '../tasks/staff-roles-screen.ts'; @@ -39,6 +44,14 @@ Given('a staff role named {string} exists', (roleName: string) => { ensureMockStaffRole(roleName); }); +Given('the staff role {string} is assigned to {word}', (roleName: string, _actorName: string) => { + assignMockStaffRoleToCurrentAuth(roleName); +}); + +Given('the staff role deletion will fail', () => { + failStaffRoleDeletions(); +}); + When('{word} views the staff roles list', async (actorName: string) => { await actorCalled(actorName).attemptsTo(OpenStaffRolesList()); }); @@ -69,6 +82,22 @@ When('{word} opens the edit screen for the staff role {string}', async (actorNam await actorCalled(actorName).attemptsTo(OpenStaffRoleEditScreenRecordingRoute(roleName)); }); +When('{word} deletes the staff role {string}', async (actorName: string, roleName: string) => { + await actorCalled(actorName).attemptsTo(DeleteStaffRoleViaForm(roleName)); +}); + +When('{word} attempts to delete the staff role {string}', async (actorName: string, roleName: string) => { + await actorCalled(actorName).attemptsTo(DeleteStaffRoleViaForm(roleName)); +}); + +When('{word} starts deleting the staff role {string}', async (actorName: string, roleName: string) => { + await actorCalled(actorName).attemptsTo(StartDeletingStaffRole(roleName)); +}); + +When('{word} cancels the staff role deletion', async (actorName: string) => { + await actorCalled(actorName).attemptsTo(CancelStaffRoleDeletion()); +}); + Then('the staff roles list should include the default staff roles', async () => { const listed = await actorInTheSpotlight().answer(ListedStaffRoleNames()); const missing = DEFAULT_STAFF_ROLE_NAMES.filter((name) => !listed.includes(name)); @@ -121,6 +150,44 @@ Then('the staff roles list should include {string}', async (roleName: string) => } }); +Then('the staff role should be deleted successfully', async () => { + const actor = actorInTheSpotlight(); + const mutation = await actor.answer(LastStaffRoleMutation()); + if (mutation?.success !== true) { + throw new Error(`Expected the staff role delete mutation to succeed, but got: ${mutation?.errorMessage ?? 'no mutation was performed'}`); + } + if (!(await actor.answer(SuccessFeedbackVisible()))) { + throw new Error('Expected a success message after deleting the staff role'); + } + if (!(await actor.answer(StaffRolesListVisible()))) { + throw new Error('Expected successful deletion to return to the staff roles list'); + } +}); + +Then('the staff roles list should not include {string}', async (roleName: string) => { + const actor = actorInTheSpotlight(); + const mutation = await actor.answer(LastStaffRoleMutation()); + if (mutation?.success !== true) { + throw new Error(`Expected the staff role delete mutation to succeed before checking the list, but got: ${mutation?.errorMessage ?? 'no mutation was performed'}`); + } + if (!(await actor.answer(StaffRolesListExcludes(roleName)))) { + throw new Error(`Expected the staff roles list to no longer include "${roleName}"`); + } +}); + +Then('{word} should not see a delete action for the staff role', async (_actorName: string) => { + if (await actorInTheSpotlight().answer(DeleteActionVisible())) { + throw new Error('Expected the staff role delete action to be hidden, but it is visible'); + } +}); + +Then('{word} should see a delete confirmation explaining assigned staff users will be reassigned', async (_actorName: string) => { + const confirmationText = await actorInTheSpotlight().answer(DeleteConfirmationText()); + if (!/reassigned/i.test(confirmationText) || !/default role/i.test(confirmationText)) { + throw new Error(`Expected the delete confirmation to explain reassignment to the matching default role, but got: "${confirmationText}"`); + } +}); + Then('{word} should see a staff role error containing {string}', async (_actorName: string, expectedFragment: string) => { if (!(await actorInTheSpotlight().answer(ErrorFeedbackContaining(expectedFragment)))) { throw new Error(`Expected a staff role error message containing "${expectedFragment}"`); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/delete-staff-role.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/delete-staff-role.ts new file mode 100644 index 000000000..f816dd8ff --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/delete-staff-role.ts @@ -0,0 +1,59 @@ +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { type Actor, Task } from '@serenity-js/core'; +import { flushUi, formPageFor, OpenEditFormForRole, waitUntilUi } from './staff-roles-screen.ts'; + +/** + * Task that opens the details (edit) screen of a staff role and clicks the + * delete action, leaving the confirmation Popconfirm open. + */ +export const StartDeletingStaffRole = (roleName: string): Task => + Task.where( + `#actor starts deleting the staff role "${roleName}"`, + new TaskStep(`#actor opens the delete confirmation for "${roleName}"`, async (actor) => { + await actor.attemptsTo(OpenEditFormForRole(roleName)); + const formPage = formPageFor(actor); + await waitUntilUi(() => formPage.deleteRoleButton.isVisible(), `Expected a delete action for the staff role "${roleName}"`); + await formPage.clickDeleteRole(); + await flushUi(); + await waitUntilUi(() => formPage.deleteConfirmation.isVisible(), 'Expected the delete confirmation to appear'); + }), + ); + +/** + * Task that confirms the currently open delete confirmation. + */ +const ConfirmStaffRoleDeletion = (): Task => + Task.where( + '#actor confirms the staff role deletion', + new TaskStep('#actor accepts the delete confirmation', async (actor) => { + const formPage = formPageFor(actor); + await formPage.confirmDelete(); + await flushUi(); + }), + ); + +/** + * Task that cancels the currently open delete confirmation. + */ +export const CancelStaffRoleDeletion = (): Task => + Task.where( + '#actor cancels the staff role deletion', + new TaskStep('#actor dismisses the delete confirmation', async (actor) => { + const formPage = formPageFor(actor); + await formPage.cancelDelete(); + await flushUi(); + }), + ); + +/** + * Task that deletes a staff role through the details screen: opens the role, + * clicks the delete action, and confirms the Popconfirm. + */ +export const DeleteStaffRoleViaForm = (roleName: string): Task => + Task.where( + `#actor deletes the staff role "${roleName}"`, + new TaskStep(`#actor deletes "${roleName}" via the details screen`, async (actor) => { + await actor.attemptsTo(StartDeletingStaffRole(roleName)); + await actor.attemptsTo(ConfirmStaffRoleDeletion()); + }), + ); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/abilities/staff-user-page.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/abilities/staff-user-page.ts new file mode 100644 index 000000000..dc6dd5ad3 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/abilities/staff-user-page.ts @@ -0,0 +1,35 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { StaffUserDetailPage, StaffUsersListPage } from '@ocom-verification/verification-shared/pages'; +import type { Page } from 'playwright'; +import { waitUntil } from './staff-portal-page.ts'; + +const USERS_LIST_PATH = '/staff/user-management/staff-users'; + +/** Staff users list page object bound to the given browser page. */ +const usersListPageOn = (page: Page): StaffUsersListPage => new StaffUsersListPage(new PlaywrightPageAdapter(page)); + +/** Staff user detail page object bound to the given browser page. */ +export const userDetailPageOn = (page: Page): StaffUserDetailPage => new StaffUserDetailPage(new PlaywrightPageAdapter(page)); + +/** Navigate to the staff users list and wait for the table rows to render. */ +const openUsersList = async (page: Page): Promise => { + await page.goto(USERS_LIST_PATH, { waitUntil: 'networkidle' }); + const listPage = usersListPageOn(page); + await waitUntil(async () => await listPage.heading.isVisible(), 'Timed out waiting for the staff users list to render'); + return listPage; +}; + +/** + * Open the detail screen for a staff user from the users list and wait for the + * assigned-role section to load. Performs a full page load so the screen shows + * fresh (uncached) data. + */ +export const openUserDetailFor = async (page: Page, displayName: string): Promise => { + const listPage = await openUsersList(page); + await listPage.clickEditForUser(displayName); + await page.waitForURL((url) => /\/staff-users\/[^/]+$/.test(url.pathname), { timeout: 20_000 }); + const detailPage = userDetailPageOn(page); + await waitUntil(async () => await detailPage.heading.isVisible(), `Timed out waiting for the staff user detail of "${displayName}" to load`); + await waitUntil(async () => (await detailPage.displayedRoleName()) !== '', `Timed out waiting for the assigned role of "${displayName}" to load`); + return detailPage; +}; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/delete-staff-role-actions.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/delete-staff-role-actions.ts new file mode 100644 index 000000000..25409c95b --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/delete-staff-role-actions.ts @@ -0,0 +1,33 @@ +import { Interaction, the } from '@serenity-js/core'; +import { formPageOn, staffPortalPageOf, waitUntil } from '../abilities/staff-portal-page.ts'; + +/** + * Low-level interaction that clicks the delete action on the staff role + * details (edit) screen, leaving the confirmation Popconfirm open. + */ +export const ClickDeleteStaffRole = () => + Interaction.where(the`#actor clicks the delete staff role action`, async (actor) => { + const page = await staffPortalPageOf(actor); + const formPage = formPageOn(page); + await waitUntil(() => formPage.deleteRoleButton.isVisible(), 'Expected a delete action on the staff role details screen'); + await formPage.clickDeleteRole(); + await waitUntil(() => formPage.deleteConfirmation.isVisible(), 'Expected the delete confirmation to appear'); + }); + +/** + * Low-level interaction that accepts the currently open delete confirmation. + */ +export const ConfirmDeleteStaffRole = () => + Interaction.where(the`#actor confirms the staff role deletion`, async (actor) => { + const page = await staffPortalPageOf(actor); + await formPageOn(page).confirmDelete(); + }); + +/** + * Low-level interaction that dismisses the currently open delete confirmation. + */ +export const CancelDeleteStaffRole = () => + Interaction.where(the`#actor cancels the staff role deletion`, async (actor) => { + const page = await staffPortalPageOf(actor); + await formPageOn(page).cancelDelete(); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-role-screen.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-role-screen.ts index 3b179f93f..5673f13eb 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-role-screen.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-role-screen.ts @@ -21,6 +21,31 @@ export const StaffRolesListIncludes = (roleName: string) => return true; }); +/** Waits until the live roles list no longer includes the given role name. */ +export const StaffRolesListExcludes = (roleName: string) => + Question.about(`whether the staff roles list no longer includes "${roleName}"`, async (actor) => { + const page = await staffPortalPageOf(actor as unknown as Actor); + const listPage = await openRolesList(page); + await waitUntil(async () => !(await listPage.hasRoleNamed(roleName)), `Expected staff roles list to no longer include "${roleName}", but got: [${(await listPage.listedRoleNames()).join(', ')}]`); + return true; + }); + +/** Whether the delete action is visible on the staff role details screen. */ +export const DeleteActionVisible = () => + Question.about('whether the staff role delete action is visible', async (actor) => { + const page = await staffPortalPageOf(actor as unknown as Actor); + return await formPageOn(page).hasDeleteAction(); + }); + +/** Waits for the delete confirmation and reads its text. */ +export const DeleteConfirmationText = () => + Question.about('the staff role delete confirmation text', async (actor) => { + const page = await staffPortalPageOf(actor as unknown as Actor); + const formPage = formPageOn(page); + await waitUntil(() => formPage.deleteConfirmation.isVisible(), 'Expected the delete confirmation to be visible'); + return await formPage.deleteConfirmationText(); + }); + /** Waits for the app to navigate back to the staff roles list after a mutation. */ export const ReturnedToStaffRolesList = (failureMessage: string) => Question.about('whether the app returned to the staff roles list', async (actor) => { diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-user-screen.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-user-screen.ts new file mode 100644 index 000000000..a66dea7f2 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-user-screen.ts @@ -0,0 +1,25 @@ +import { type Actor, Question } from '@serenity-js/core'; +import { staffPortalPageOf } from '../abilities/staff-portal-page.ts'; +import { openUserDetailFor } from '../abilities/staff-user-page.ts'; + +/** + * Question that reads the role currently assigned to a staff user, polling + * with fresh page loads until the expected role shows up (staff-role deletion + * reassigns users asynchronously after the delete mutation commits). + */ +export const StaffUserAssignedRole = (displayName: string, expectedRoleName: string) => + Question.about(`the staff role assigned to "${displayName}"`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await staffPortalPageOf(actor); + const deadline = Date.now() + 30_000; + let lastSeenRoleName = ''; + while (Date.now() < deadline) { + const detailPage = await openUserDetailFor(page, displayName); + lastSeenRoleName = await detailPage.displayedRoleName(); + if (lastSeenRoleName === expectedRoleName) { + return lastSeenRoleName; + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + return lastSeenRoleName; + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts index 57bc833d3..3cf1b7e24 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts @@ -3,17 +3,24 @@ import { AfterAll, type DataTable, Given, Then, When } from '@cucumber/cucumber' import { DEFAULT_STAFF_ROLE_NAMES } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, actorInTheSpotlight } from '@serenity-js/core'; import { closeStaffPortalSessions } from '../../../shared/abilities/staff-portal-session.ts'; +import { OpenEditStaffRoleForm } from '../interactions/open-edit-staff-role-form.ts'; import { BaselineStaffRoleCount, CurrentStaffRoleCount, + DeleteActionVisible, + DeleteConfirmationText, RecordedStaffRoleNames, ReturnedToStaffRolesList, StaffRoleErrorContaining, StaffRolePermissionGranted, + StaffRolesListExcludes, StaffRolesListIncludes, StaffRoleValidationErrorFor, } from '../questions/staff-role-screen.ts'; +import { StaffUserAssignedRole } from '../questions/staff-user-screen.ts'; +import { AssignStaffRoleToUserViaDetail } from '../tasks/assign-staff-role-to-user.ts'; import { CreateStaffRoleViaForm, CreateStaffRoleWithPermissions } from '../tasks/create-staff-role.ts'; +import { CancelStaffRoleDeletion, DeleteStaffRoleViaForm, StartDeletingStaffRole } from '../tasks/delete-staff-role.ts'; import { EnsureStaffRoleExists } from '../tasks/ensure-staff-role-exists.ts'; import { GrantStaffRolePermissionViaForm } from '../tasks/grant-staff-role-permission.ts'; import { OpenStaffRolesScreenRecordingPath } from '../tasks/open-staff-roles-screen.ts'; @@ -58,6 +65,26 @@ When('{word} opens the staff roles screen', async (actorName: string) => { await actorCalled(actorName).attemptsTo(OpenStaffRolesScreenRecordingPath()); }); +When('{word} views the details of the staff role {string}', async (actorName: string, roleName: string) => { + await actorCalled(actorName).attemptsTo(OpenEditStaffRoleForm(roleName)); +}); + +When('{word} assigns the staff role {string} to the staff user {string}', async (actorName: string, roleName: string, staffUserDisplayName: string) => { + await actorCalled(actorName).attemptsTo(AssignStaffRoleToUserViaDetail(roleName, staffUserDisplayName)); +}); + +When('{word} deletes the staff role {string}', async (actorName: string, roleName: string) => { + await actorCalled(actorName).attemptsTo(DeleteStaffRoleViaForm(roleName)); +}); + +When('{word} starts deleting the staff role {string}', async (actorName: string, roleName: string) => { + await actorCalled(actorName).attemptsTo(StartDeletingStaffRole(roleName)); +}); + +When('{word} cancels the staff role deletion', async (actorName: string) => { + await actorCalled(actorName).attemptsTo(CancelStaffRoleDeletion()); +}); + Then('the staff roles list should include the default staff roles', async () => { const listed = await actorInTheSpotlight().answer(RecordedStaffRoleNames()); const missing = DEFAULT_STAFF_ROLE_NAMES.filter((name) => !listed.includes(name)); @@ -78,6 +105,34 @@ Then('the staff roles list should include {string}', async (roleName: string) => await actorInTheSpotlight().answer(StaffRolesListIncludes(roleName)); }); +Then('the staff role should be deleted successfully', async () => { + await actorInTheSpotlight().answer(ReturnedToStaffRolesList('Expected the staff role to be deleted and the app to return to the roles list')); +}); + +Then('the staff roles list should not include {string}', async (roleName: string) => { + await actorInTheSpotlight().answer(StaffRolesListExcludes(roleName)); +}); + +Then('{word} should not see a delete action for the staff role', async (_actorName: string) => { + if (await actorInTheSpotlight().answer(DeleteActionVisible())) { + throw new Error('Expected the staff role delete action to be hidden, but it is visible'); + } +}); + +Then('{word} should see a delete confirmation explaining assigned staff users will be reassigned', async (_actorName: string) => { + const confirmationText = await actorInTheSpotlight().answer(DeleteConfirmationText()); + if (!/reassigned/i.test(confirmationText) || !/default role/i.test(confirmationText)) { + throw new Error(`Expected the delete confirmation to explain reassignment to the matching default role, but got: "${confirmationText}"`); + } +}); + +Then('the staff user {string} should have the staff role {string}', async (staffUserDisplayName: string, roleName: string) => { + const assignedRole = await actorInTheSpotlight().answer(StaffUserAssignedRole(staffUserDisplayName, roleName)); + if (assignedRole !== roleName) { + throw new Error(`Expected staff user "${staffUserDisplayName}" to have the staff role "${roleName}", but got "${assignedRole || 'none'}"`); + } +}); + Then('the staff role {string} should have the permission {string} granted', async (roleName: string, permissionKey: string) => { const granted = await actorInTheSpotlight().answer(StaffRolePermissionGranted(roleName, permissionKey)); if (!granted) { diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/assign-staff-role-to-user.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/assign-staff-role-to-user.ts new file mode 100644 index 000000000..4eba5fdb1 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/assign-staff-role-to-user.ts @@ -0,0 +1,47 @@ +import { Interaction, Task, the } from '@serenity-js/core'; +import { formPageOn, staffPortalPageOf, waitUntil } from '../abilities/staff-portal-page.ts'; +import { openUserDetailFor, userDetailPageOn } from '../abilities/staff-user-page.ts'; + +/** + * Low-level interaction that opens the detail screen of a staff user from the + * staff users list. + */ +const OpenStaffUserDetail = (displayName: string) => + Interaction.where(the`#actor opens the staff user detail of "${displayName}"`, async (actor) => { + const page = await staffPortalPageOf(actor); + await openUserDetailFor(page, displayName); + }); + +/** + * Low-level interaction that picks a role in the assigned-role select of the + * staff user detail screen currently on screen. + */ +const SelectAssignedStaffRole = (roleName: string) => + Interaction.where(the`#actor selects the assigned staff role "${roleName}"`, async (actor) => { + const page = await staffPortalPageOf(actor); + await userDetailPageOn(page).selectRole(roleName); + }); + +/** + * Low-level interaction that saves the assigned-role selection and waits for + * the success feedback. + */ +const SaveAssignedStaffRole = () => + Interaction.where(the`#actor saves the assigned staff role`, async (actor) => { + const page = await staffPortalPageOf(actor); + await userDetailPageOn(page).clickSave(); + const feedback = formPageOn(page); + await waitUntil(() => feedback.successFeedback.isVisible(), 'Expected a success message after assigning the staff role'); + }); + +/** + * Task that assigns a staff role to a staff user through the staff user + * detail screen. + */ +export const AssignStaffRoleToUserViaDetail = (roleName: string, staffUserDisplayName: string) => + Task.where( + the`#actor assigns the staff role "${roleName}" to the staff user "${staffUserDisplayName}" via the staff portal`, + OpenStaffUserDetail(staffUserDisplayName), + SelectAssignedStaffRole(roleName), + SaveAssignedStaffRole(), + ); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/delete-staff-role.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/delete-staff-role.ts new file mode 100644 index 000000000..093356d58 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/delete-staff-role.ts @@ -0,0 +1,20 @@ +import { Task, the } from '@serenity-js/core'; +import { CancelDeleteStaffRole, ClickDeleteStaffRole, ConfirmDeleteStaffRole } from '../interactions/delete-staff-role-actions.ts'; +import { OpenEditStaffRoleForm } from '../interactions/open-edit-staff-role-form.ts'; + +/** + * Task that deletes a staff role through the details screen: opens the role, + * clicks the delete action, and confirms the Popconfirm. + */ +export const DeleteStaffRoleViaForm = (roleName: string) => Task.where(the`#actor deletes the staff role "${roleName}" via the staff portal`, OpenEditStaffRoleForm(roleName), ClickDeleteStaffRole(), ConfirmDeleteStaffRole()); + +/** + * Task that opens the details screen of a staff role and clicks the delete + * action, leaving the confirmation Popconfirm open. + */ +export const StartDeletingStaffRole = (roleName: string) => Task.where(the`#actor starts deleting the staff role "${roleName}"`, OpenEditStaffRoleForm(roleName), ClickDeleteStaffRole()); + +/** + * Task that cancels the currently open staff role delete confirmation. + */ +export const CancelStaffRoleDeletion = () => Task.where(the`#actor cancels the staff role deletion`, CancelDeleteStaffRole()); diff --git a/packages/ocom-verification/verification-shared/src/pages/index.ts b/packages/ocom-verification/verification-shared/src/pages/index.ts index 92c1e1c6b..3ee21d502 100644 --- a/packages/ocom-verification/verification-shared/src/pages/index.ts +++ b/packages/ocom-verification/verification-shared/src/pages/index.ts @@ -2,3 +2,5 @@ export { CommunityPage } from './community.page.ts'; export { HomePage } from './home.page.ts'; export { STAFF_ROLE_PERMISSION_LABELS, StaffRoleFormPage } from './staff-role-form.page.ts'; export { StaffRolesListPage } from './staff-roles-list.page.ts'; +export { StaffUserDetailPage } from './staff-user-detail.page.ts'; +export { StaffUsersListPage } from './staff-users-list.page.ts'; diff --git a/packages/ocom-verification/verification-shared/src/pages/staff-role-form.page.ts b/packages/ocom-verification/verification-shared/src/pages/staff-role-form.page.ts index f72d1270a..3b27e9095 100644 --- a/packages/ocom-verification/verification-shared/src/pages/staff-role-form.page.ts +++ b/packages/ocom-verification/verification-shared/src/pages/staff-role-form.page.ts @@ -60,6 +60,50 @@ export class StaffRoleFormPage extends AdapterBackedPageObject { return this.adapter.locator('.ant-message-success'); } + /** Red delete action rendered in the upper-right area of the details (edit) screen. */ + get deleteRoleButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /^Delete Role$/ }); + } + + /** Popconfirm content shown after clicking the delete action. */ + get deleteConfirmation(): ElementHandle { + return this.adapter.locator('.ant-popconfirm'); + } + + /** Confirm button inside the delete Popconfirm (scoped to avoid matching form buttons). */ + get confirmDeleteButton(): ElementHandle { + return this.adapter.locator('.ant-popconfirm-buttons .ant-btn-primary'); + } + + /** Cancel button inside the delete Popconfirm (scoped to avoid matching the form cancel button). */ + get cancelDeleteButton(): ElementHandle { + return this.adapter.locator('.ant-popconfirm-buttons .ant-btn:not(.ant-btn-primary)'); + } + + /** Whether the delete action is rendered for the current role/viewer. */ + async hasDeleteAction(): Promise { + return await this.deleteRoleButton.isVisible(); + } + + async clickDeleteRole(): Promise { + await this.deleteRoleButton.click(); + } + + /** Text of the delete confirmation (title + description). */ + async deleteConfirmationText(): Promise { + return (await this.deleteConfirmation.textContent())?.trim() ?? ''; + } + + async confirmDelete(): Promise { + await this.confirmDeleteButton.waitFor({ state: 'visible' }); + await this.confirmDeleteButton.click(); + } + + async cancelDelete(): Promise { + await this.cancelDeleteButton.waitFor({ state: 'visible' }); + await this.cancelDeleteButton.click(); + } + async fillRoleName(value: string): Promise { await this.roleNameInput.fill(value); } diff --git a/packages/ocom-verification/verification-shared/src/pages/staff-user-detail.page.ts b/packages/ocom-verification/verification-shared/src/pages/staff-user-detail.page.ts new file mode 100644 index 000000000..bc7da2ad2 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/staff-user-detail.page.ts @@ -0,0 +1,59 @@ +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; + +/** + * Page object for the staff user detail screen (`/staff/user-management/users/edit/:id`). + * + * Works against both the DOM adapter (component acceptance tests) and the + * Playwright adapter (browser E2E tests). + */ +export class StaffUserDetailPage extends AdapterBackedPageObject { + get heading(): ElementHandle { + return this.adapter.getByText('Staff User Details'); + } + + /** Assigned-role antd Select trigger. */ + get roleSelect(): ElementHandle { + return this.adapter.getByRole('combobox'); + } + + get saveButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /^Save$/ }); + } + + /** + * Role name currently shown by the assigned-role select — the selected + * option label when a selection exists, otherwise the placeholder (which + * the screen sets to the persisted role name). Handles both the antd v5 + * (`selection-item`) and antd v6 (`content`) select markup. + */ + async displayedRoleName(): Promise { + const selection = this.adapter.locator('.ant-select-selection-item, .ant-select-content'); + if (await selection.isVisible()) { + const title = await selection.getAttribute('title'); + if (title) { + return title; + } + const text = (await selection.textContent())?.trim(); + if (text) { + return text; + } + } + const placeholder = this.adapter.locator('.ant-select-selection-placeholder, .ant-select-placeholder'); + if (await placeholder.isVisible()) { + return (await placeholder.textContent())?.trim() ?? ''; + } + return ''; + } + + /** Open the assigned-role dropdown and pick the option with the given role name. */ + async selectRole(roleName: string): Promise { + await this.roleSelect.click(); + const option = this.adapter.locator(`.ant-select-item-option[title="${roleName}"]`); + await option.waitFor({ state: 'visible' }); + await option.click(); + } + + async clickSave(): Promise { + await this.saveButton.click(); + } +} diff --git a/packages/ocom-verification/verification-shared/src/pages/staff-users-list.page.ts b/packages/ocom-verification/verification-shared/src/pages/staff-users-list.page.ts new file mode 100644 index 000000000..8d7c2c5dc --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/staff-users-list.page.ts @@ -0,0 +1,32 @@ +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; + +/** + * Page object for the staff users list screen (`/staff/user-management/users`). + * + * Works against both the DOM adapter (component acceptance tests) and the + * Playwright adapter (browser E2E tests). + */ +export class StaffUsersListPage extends AdapterBackedPageObject { + get heading(): ElementHandle { + return this.adapter.getByText(/Staff Users \(/); + } + + /** Click the row-level Edit action for the staff user with the given display name. */ + async clickEditForUser(displayName: string): Promise { + const rows = await this.adapter.locatorAll('.ant-table-tbody tr.ant-table-row'); + for (const row of rows) { + const text = await row.textContent(); + if (text?.includes(displayName)) { + const buttons = await row.querySelectorAll('button, a'); + for (const button of buttons) { + const label = await button.textContent(); + if (label?.trim() === 'Edit') { + await button.click(); + return; + } + } + } + } + throw new Error(`No Edit action found for staff user "${displayName}"`); + } +} diff --git a/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-role-management.feature b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-role-management.feature index c24a4abfc..13fc47a5c 100644 --- a/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-role-management.feature +++ b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-role-management.feature @@ -127,3 +127,92 @@ Feature: Staff role management And a staff role named "Restricted Role" exists When Alice opens the edit screen for the staff role "Restricted Role" Then Alice should be directed to "/unauthorized" + + Scenario: Delete a non-default staff role + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Obsolete Coordinator" exists + When Alice deletes the staff role "Obsolete Coordinator" + Then the staff role should be deleted successfully + And the staff roles list should not include "Obsolete Coordinator" + + @skip-ui + Scenario: Deleting a staff role reassigns its staff users to the matching default role + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Interim Case Manager" exists + And Alice assigns the staff role "Interim Case Manager" to the staff user "Staff User" + When Alice deletes the staff role "Interim Case Manager" + Then the staff role should be deleted successfully + And the staff user "Staff User" should have the staff role "Default Case Manager" + + @api-only + Scenario: Default staff roles cannot be deleted + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Default Case Manager" exists + When Alice attempts to delete the staff role "Default Case Manager" + Then she should see a staff role error containing "default" + And the staff roles list should include "Default Case Manager" + + @skip-api + Scenario: The delete action is not available for a default staff role + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Default Case Manager" exists + When Alice views the details of the staff role "Default Case Manager" + Then she should not see a delete action for the staff role + + @api-only + Scenario: Staff user without the remove-role permission cannot delete a staff role + Given Alice is an authenticated "case manager" staff user + And a staff role named "Seeded Case Manager" exists + When Alice attempts to delete the staff role "Seeded Case Manager" + Then she should see a staff role error containing "do not have permission" + And the staff roles list should include "Seeded Case Manager" + + @api-only + Scenario: Staff users cannot delete the role currently assigned to them + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Seeded Tech Admin" exists + When Alice attempts to delete the staff role "Seeded Tech Admin" + Then she should see a staff role error containing "currently assigned" + And the staff roles list should include "Seeded Tech Admin" + + @ui-only + Scenario: Staff user without the remove-role permission sees no delete action + Given Alice is an authenticated staff user with only the "canEditRole" role permission + And a staff role named "Guarded Role" exists + When Alice views the details of the staff role "Guarded Role" + Then she should not see a delete action for the staff role + + @ui-only + Scenario: The delete action is not available for the current staff user's role + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Current Tech Admin Role" exists + And the staff role "Current Tech Admin Role" is assigned to Alice + When Alice views the details of the staff role "Current Tech Admin Role" + Then she should not see a delete action for the staff role + + @ui-only + Scenario: Staff user with only the remove-role permission can delete a staff role + Given Alice is an authenticated staff user with only the "canRemoveRole" role permission + And a staff role named "Remove Only Role" exists + When Alice deletes the staff role "Remove Only Role" + Then the staff roles list should not include "Remove Only Role" + + @skip-api + Scenario: Cancelling the delete confirmation leaves the staff role intact + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Retained Role" exists + When Alice starts deleting the staff role "Retained Role" + Then she should see a delete confirmation explaining assigned staff users will be reassigned + When Alice cancels the staff role deletion + And Alice views the staff roles list + Then the staff roles list should include "Retained Role" + + @ui-only + Scenario: Deletion failure keeps the staff role and shows an error + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Resilient Role" exists + And the staff role deletion will fail + When Alice attempts to delete the staff role "Resilient Role" + Then she should see a staff role error containing "Failed to delete" + When Alice views the staff roles list + Then the staff roles list should include "Resilient Role" diff --git a/packages/ocom/application-services/package.json b/packages/ocom/application-services/package.json index f22fef4f1..af4a35bef 100644 --- a/packages/ocom/application-services/package.json +++ b/packages/ocom/application-services/package.json @@ -26,6 +26,8 @@ "clean": "rimraf dist" }, "dependencies": { + "@cellix/domain-seedwork": "workspace:*", + "@cellix/mongoose-seedwork": "workspace:*", "@ocom/context-spec": "workspace:*", "@ocom/domain": "workspace:*", "@ocom/persistence": "workspace:*", diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/delete-and-reassign.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/delete-and-reassign.test.ts deleted file mode 100644 index c282fd33d..000000000 --- a/packages/ocom/application-services/src/contexts/user/staff-role/delete-and-reassign.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -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 { deleteAndReassign } from './delete-and-reassign.ts'; - -const test = { for: describeFeature }; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const feature = await loadFeature(path.resolve(__dirname, 'features/delete-and-reassign.feature')); - -function makeMockStaffRole(overrides: Partial = {}) { - const baseRole = { - id: '507f1f77bcf86cd799439011', - roleName: 'Test Role', - isDefault: false, - permissions: { - communityPermissions: { - canManageStaffRolesAndPermissions: false, - canManageAllCommunities: false, - canDeleteCommunities: false, - canChangeCommunityOwner: false, - canReIndexSearchCollections: false, - }, - }, - roleType: null, - createdAt: new Date(), - updatedAt: new Date(), - schemaVersion: '1.0', - ...overrides, - }; - - // Return a mock StaffRole object with the deleteAndReassignTo method - return { - ...baseRole, - deleteAndReassignTo: vi.fn(), - } as unknown as Domain.Contexts.User.StaffRole.StaffRole; -} - -function makeMockRepo(overrides: Partial> = {}) { - return { - getById: vi.fn(), - save: vi.fn(), - ...overrides, - } as unknown as Domain.Contexts.User.StaffRole.StaffRoleRepository; -} - -test.for(feature, ({ Scenario, BeforeEachScenario }) => { - let dataSources: DataSources; - let deleteAndReassignRole: (command: { roleId: string; reassignToRoleId: string }) => Promise; - - BeforeEachScenario(() => { - dataSources = { - domainDataSource: { - User: { - StaffRole: { - StaffRoleUnitOfWork: { - withScopedTransaction: vi.fn(), - }, - }, - }, - }, - } as unknown as DataSources; - - deleteAndReassignRole = deleteAndReassign(dataSources); - }); - - Scenario('Deleting and reassigning a staff role successfully', ({ Given, When, Then }) => { - Given('a staff role with id "507f1f77bcf86cd799439011" exists', () => { - // Mock will be set up in When step - }); - - Given('a staff role with id "507f1f77bcf86cd799439012" exists', () => { - // Mock will be set up in When step - }); - - When('I delete and reassign role "507f1f77bcf86cd799439011" to role "507f1f77bcf86cd799439012"', async () => { - const roleToDelete = makeMockStaffRole({ id: '507f1f77bcf86cd799439011' }); - const roleToAssign = makeMockStaffRole({ id: '507f1f77bcf86cd799439012' }); - - const mockRepo = makeMockRepo({ - getById: vi.fn().mockResolvedValueOnce(roleToDelete).mockResolvedValueOnce(roleToAssign), - save: vi.fn().mockResolvedValue(roleToDelete), - }); - - vi.mocked(dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction).mockImplementation(async (callback) => { - await callback(mockRepo); - }); - - await deleteAndReassignRole({ roleId: '507f1f77bcf86cd799439011', reassignToRoleId: '507f1f77bcf86cd799439012' }); - }); - - Then('the operation should complete successfully', () => { - // If no error was thrown, the test passes - expect(true).toBe(true); - }); - }); - - Scenario('Deleting a staff role that does not exist', ({ Given, When, Then }) => { - let error: Error; - - Given('no staff role with id "507f1f77bcf86cd799439011" exists', () => { - // Mock will be set up in When step - }); - - When('I delete and reassign role "507f1f77bcf86cd799439011" to role "507f1f77bcf86cd799439012"', async () => { - const mockRepo = makeMockRepo({ - getById: vi.fn().mockRejectedValue(new Error('Not found')), - }); - - vi.mocked(dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction).mockImplementation(async (callback) => { - await callback(mockRepo); - }); - - try { - await deleteAndReassignRole({ roleId: '507f1f77bcf86cd799439011', reassignToRoleId: '507f1f77bcf86cd799439012' }); - } catch (err) { - error = err as Error; - } - }); - - Then('it should throw an error', () => { - expect(error).toBeDefined(); - }); - }); - - Scenario('Reassigning to a staff role that does not exist', ({ Given, When, Then }) => { - let error: Error; - - Given('a staff role with id "507f1f77bcf86cd799439011" exists', () => { - // Mock will be set up in When step - }); - - Given('no staff role with id "507f1f77bcf86cd799439012" exists', () => { - // Mock will be set up in When step - }); - - When('I delete and reassign role "507f1f77bcf86cd799439011" to role "507f1f77bcf86cd799439012"', async () => { - const roleToDelete = makeMockStaffRole({ id: '507f1f77bcf86cd799439011' }); - - const mockRepo = makeMockRepo({ - save: vi.fn().mockResolvedValue(roleToDelete), - getById: vi.fn().mockResolvedValueOnce(roleToDelete).mockRejectedValueOnce(new Error('Not found')), - }); - - vi.mocked(dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction).mockImplementation(async (callback) => { - await callback(mockRepo); - }); - - try { - await deleteAndReassignRole({ roleId: '507f1f77bcf86cd799439011', reassignToRoleId: '507f1f77bcf86cd799439012' }); - } catch (err) { - error = err as Error; - } - }); - - Then('it should throw an error', () => { - expect(error).toBeDefined(); - }); - }); -}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/delete-and-reassign.ts b/packages/ocom/application-services/src/contexts/user/staff-role/delete-and-reassign.ts deleted file mode 100644 index 505b65faa..000000000 --- a/packages/ocom/application-services/src/contexts/user/staff-role/delete-and-reassign.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { DataSources } from '@ocom/persistence'; - -export interface StaffRoleDeleteAndReassignCommand { - roleId: string; - reassignToRoleId: string; -} - -export const deleteAndReassign = (dataSources: DataSources) => { - return async (command: StaffRoleDeleteAndReassignCommand): Promise => { - await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction(async (repository) => { - const roleToDelete = await repository.getById(command.roleId); - const roleToAssign = await repository.getById(command.reassignToRoleId); - roleToDelete.deleteAndReassignTo(roleToAssign); - await repository.save(roleToDelete); - }); - }; -}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/delete.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/delete.test.ts new file mode 100644 index 000000000..f51c21d8c --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/delete.test.ts @@ -0,0 +1,153 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; +import { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { deleteStaffRole, type StaffRoleDeleteResult } from './delete.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/delete.feature')); + +function makeMockStaffRole() { + return { + id: '507f1f77bcf86cd799439011', + roleName: 'Test Role', + isDefault: false, + requestDelete: vi.fn(), + } as unknown as Domain.Contexts.User.StaffRole.StaffRole; +} + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources; + let deleteRole: (command: { roleId: string; actorStaffUserId: string; actorStaffRoleId?: string }) => Promise; + let deletionResult: StaffRoleDeleteResult | undefined; + let mockRepo: { + getById: ReturnType; + save: ReturnType; + }; + let mockUnitOfWork: { + withScopedTransaction: ReturnType; + }; + let mockRole: ReturnType; + let thrownError: Error | null; + + BeforeEachScenario(() => { + thrownError = null; + deletionResult = undefined; + mockRole = makeMockStaffRole(); + mockRepo = { + getById: vi.fn(), + save: vi.fn(async (role: unknown) => role), + }; + mockUnitOfWork = { + withScopedTransaction: vi.fn(async (fn: (repo: typeof mockRepo) => Promise) => { + await fn(mockRepo); + }), + }; + dataSources = { + domainDataSource: { + User: { + StaffRole: { + StaffRoleUnitOfWork: mockUnitOfWork, + }, + }, + }, + } as unknown as DataSources; + + deleteRole = deleteStaffRole(dataSources); + }); + + Scenario('Deleting a staff role successfully', ({ Given, When, Then, And }) => { + Given('a staff role with id "507f1f77bcf86cd799439011" exists', () => { + mockRepo.getById.mockResolvedValue(mockRole); + }); + + When('I delete role "507f1f77bcf86cd799439011"', async () => { + deletionResult = await deleteRole({ roleId: '507f1f77bcf86cd799439011', actorStaffUserId: 'actor-1', actorStaffRoleId: 'actor-role-1' }); + }); + + Then('the role should be marked for deletion and saved', () => { + expect(mockRepo.getById).toHaveBeenCalledWith('507f1f77bcf86cd799439011'); + expect(mockRole.requestDelete).toHaveBeenCalledWith('actor-1', 'actor-role-1'); + expect(mockRepo.save).toHaveBeenCalledWith(mockRole); + }); + And('reassignment should not be reported as pending', () => { + expect(deletionResult).toEqual({ reassignmentPending: false }); + }); + }); + + Scenario('Retaining a durable tombstone when reassignment processing fails', ({ Given, And, When, Then }) => { + let postCommitError: MongooseSeedwork.PostCommitEventError; + Given('a staff role with id "507f1f77bcf86cd799439011" exists', () => { + mockRepo.getById.mockResolvedValue(mockRole); + }); + And('its deletion commits but the StaffRoleDeletedEvent handler fails', () => { + const event = new Domain.Events.StaffRoleDeletedEvent('507f1f77bcf86cd799439011'); + event.payload = { + deletedRoleId: '507f1f77bcf86cd799439011', + enterpriseAppRole: 'Staff.CaseManager', + actorStaffUserId: 'actor-1', + }; + postCommitError = new MongooseSeedwork.PostCommitEventError(event, new Error('reassignment failed')); + mockUnitOfWork.withScopedTransaction.mockImplementationOnce(async (fn: (repo: typeof mockRepo) => Promise) => { + await fn(mockRepo); + throw postCommitError; + }); + }); + When('I delete role "507f1f77bcf86cd799439011"', async () => { + deletionResult = await deleteRole({ roleId: '507f1f77bcf86cd799439011', actorStaffUserId: 'actor-1' }); + }); + Then('the deletion tombstone should remain saved for recovery', () => { + expect(mockRepo.save).toHaveBeenCalledWith(mockRole); + }); + And('reassignment should be reported as pending', () => { + expect(deletionResult).toEqual({ reassignmentPending: true }); + }); + }); + + Scenario('Deleting a staff role that does not exist', ({ Given, When, Then }) => { + Given('no staff role with id "507f1f77bcf86cd799439011" exists', () => { + mockRepo.getById.mockRejectedValue(new Error('StaffRole with id 507f1f77bcf86cd799439011 not found')); + }); + + When('I try to delete role "507f1f77bcf86cd799439011"', async () => { + try { + await deleteRole({ roleId: '507f1f77bcf86cd799439011', actorStaffUserId: 'actor-1' }); + } catch (error) { + thrownError = error as Error; + } + }); + + Then('it should throw an error', () => { + expect(thrownError).not.toBeNull(); + expect(thrownError?.message).toContain('not found'); + expect(mockRepo.save).not.toHaveBeenCalled(); + }); + }); + + Scenario('Deleting a staff role the domain refuses to delete', ({ Given, When, Then }) => { + Given('a staff role with id "507f1f77bcf86cd799439011" exists whose deletion is not permitted', () => { + mockRole = makeMockStaffRole(); + (mockRole.requestDelete as ReturnType).mockImplementation(() => { + throw new Error('You do not have permission to delete this role'); + }); + mockRepo.getById.mockResolvedValue(mockRole); + }); + + When('I try to delete role "507f1f77bcf86cd799439011"', async () => { + try { + await deleteRole({ roleId: '507f1f77bcf86cd799439011', actorStaffUserId: 'actor-1' }); + } catch (error) { + thrownError = error as Error; + } + }); + + Then('it should throw a permission error and not save the role', () => { + expect(thrownError?.message).toContain('do not have permission'); + expect(mockRepo.save).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/delete.ts b/packages/ocom/application-services/src/contexts/user/staff-role/delete.ts new file mode 100644 index 000000000..56971dfb8 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/delete.ts @@ -0,0 +1,33 @@ +import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; +import type { DataSources } from '@ocom/persistence'; + +export interface StaffRoleDeleteCommand { + roleId: string; + actorStaffUserId: string; + actorStaffRoleId?: string; +} + +export interface StaffRoleDeleteResult { + reassignmentPending: boolean; +} + +export const deleteStaffRole = (dataSources: DataSources) => { + return async (command: StaffRoleDeleteCommand): Promise => { + const unitOfWork = dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork; + + try { + await unitOfWork.withScopedTransaction(async (repository) => { + const roleToDelete = await repository.getById(command.roleId); + roleToDelete.requestDelete(command.actorStaffUserId, command.actorStaffRoleId); + await repository.save(roleToDelete); + }); + } catch (error) { + if (error instanceof MongooseSeedwork.PostCommitEventError) { + return { reassignmentPending: true }; + } + throw error; + } + + return { reassignmentPending: false }; + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/features/delete-and-reassign.feature b/packages/ocom/application-services/src/contexts/user/staff-role/features/delete-and-reassign.feature deleted file mode 100644 index 096d315fd..000000000 --- a/packages/ocom/application-services/src/contexts/user/staff-role/features/delete-and-reassign.feature +++ /dev/null @@ -1,18 +0,0 @@ -Feature: Deleting and reassigning a staff role - - Scenario: Deleting and reassigning a staff role successfully - Given a staff role with id "507f1f77bcf86cd799439011" exists - Given a staff role with id "507f1f77bcf86cd799439012" exists - When I delete and reassign role "507f1f77bcf86cd799439011" to role "507f1f77bcf86cd799439012" - Then the operation should complete successfully - - Scenario: Deleting a staff role that does not exist - Given no staff role with id "507f1f77bcf86cd799439011" exists - When I delete and reassign role "507f1f77bcf86cd799439011" to role "507f1f77bcf86cd799439012" - Then it should throw an error - - Scenario: Reassigning to a staff role that does not exist - Given a staff role with id "507f1f77bcf86cd799439011" exists - Given no staff role with id "507f1f77bcf86cd799439012" exists - When I delete and reassign role "507f1f77bcf86cd799439011" to role "507f1f77bcf86cd799439012" - Then it should throw an error \ No newline at end of file diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/features/delete.feature b/packages/ocom/application-services/src/contexts/user/staff-role/features/delete.feature new file mode 100644 index 000000000..238d5d5a8 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/features/delete.feature @@ -0,0 +1,24 @@ +Feature: Deleting a staff role + + Scenario: Deleting a staff role successfully + Given a staff role with id "507f1f77bcf86cd799439011" exists + When I delete role "507f1f77bcf86cd799439011" + Then the role should be marked for deletion and saved + And reassignment should not be reported as pending + + Scenario: Deleting a staff role that does not exist + Given no staff role with id "507f1f77bcf86cd799439011" exists + When I try to delete role "507f1f77bcf86cd799439011" + Then it should throw an error + + Scenario: Deleting a staff role the domain refuses to delete + Given a staff role with id "507f1f77bcf86cd799439011" exists whose deletion is not permitted + When I try to delete role "507f1f77bcf86cd799439011" + Then it should throw a permission error and not save the role + + Scenario: Retaining a durable tombstone when reassignment processing fails + Given a staff role with id "507f1f77bcf86cd799439011" exists + And its deletion commits but the StaffRoleDeletedEvent handler fails + When I delete role "507f1f77bcf86cd799439011" + Then the deletion tombstone should remain saved for recovery + And reassignment should be reported as pending diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/features/index.feature b/packages/ocom/application-services/src/contexts/user/staff-role/features/index.feature index 0e049477d..47b05bd23 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/features/index.feature +++ b/packages/ocom/application-services/src/contexts/user/staff-role/features/index.feature @@ -5,10 +5,10 @@ Feature: Staff Role Application Service When I create a staff role with name "Test Role" Then it should delegate to the create function - Scenario: Deleting and reassigning a staff role through the application service + Scenario: Deleting a staff role through the application service Given a staff role application service - When I delete and reassign role "role1" to role "role2" - Then it should delegate to the deleteAndReassign function + When I delete role "role1" + Then it should delegate to the delete function Scenario: Querying a staff role by ID through the application service Given a staff role application service diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/features/query-by-id.feature b/packages/ocom/application-services/src/contexts/user/staff-role/features/query-by-id.feature index 085837c39..2b9999d57 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/features/query-by-id.feature +++ b/packages/ocom/application-services/src/contexts/user/staff-role/features/query-by-id.feature @@ -10,6 +10,11 @@ Feature: Querying a staff role by ID When I query for staff role with id "507f1f77bcf86cd799439011" Then it should return null + Scenario: Querying a logically deleted staff role by ID + Given a logically deleted staff role with id "507f1f77bcf86cd799439011" exists + When I query for staff role with id "507f1f77bcf86cd799439011" + Then it should return null + Scenario: Querying a staff role by ID when repository throws an error Given the repository will throw a database error When I query for staff role with id "507f1f77bcf86cd799439011" diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/features/query-by-role-name.feature b/packages/ocom/application-services/src/contexts/user/staff-role/features/query-by-role-name.feature index 0bc6e49e5..9637edeec 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/features/query-by-role-name.feature +++ b/packages/ocom/application-services/src/contexts/user/staff-role/features/query-by-role-name.feature @@ -10,6 +10,11 @@ Feature: Querying a staff role by role name When I query for staff role with name "Test Role" Then it should return null + Scenario: Querying a logically deleted staff role by role name + Given a logically deleted staff role with name "Test Role" exists + When I query for staff role with name "Test Role" + Then it should return null + Scenario: Querying a staff role by role name when repository throws an error Given the repository will throw a database error When I query for staff role with name "Test Role" diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/features/update.feature b/packages/ocom/application-services/src/contexts/user/staff-role/features/update.feature index 175bb89ef..c630a378c 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/features/update.feature +++ b/packages/ocom/application-services/src/contexts/user/staff-role/features/update.feature @@ -53,3 +53,9 @@ Feature: Update staff role Given a staff role with id "role-lookup" exists in the repository When I call update with roleId "role-lookup" and roleName "Any Role" Then getById should have been called with "role-lookup" + + Scenario: Rejecting an update to a logically deleted role + Given a deleted staff role with id "role-deleted" exists in the repository + When I try to update roleId "role-deleted" + Then the deleted staff role should not be saved + And a not found error should be reported diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/index.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/index.test.ts index 5ce2ac106..951584446 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/index.test.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/index.test.ts @@ -1,17 +1,17 @@ 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 type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; // Mock the individual service modules vi.mock('./create.ts', () => ({ create: vi.fn(), })); -vi.mock('./delete-and-reassign.ts', () => ({ - deleteAndReassign: vi.fn(), +vi.mock('./delete.ts', () => ({ + deleteStaffRole: vi.fn(), })); vi.mock('./query-by-id.ts', () => ({ @@ -22,9 +22,9 @@ vi.mock('./query-by-role-name.ts', () => ({ queryByRoleName: vi.fn(), })); -import { StaffRole } from './index.ts'; import { create } from './create.ts'; -import { deleteAndReassign } from './delete-and-reassign.ts'; +import { deleteStaffRole } from './delete.ts'; +import { StaffRole } from './index.ts'; import { queryById } from './query-by-id.ts'; import { queryByRoleName } from './query-by-role-name.ts'; @@ -64,12 +64,12 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { // Set up mock implementations const mockCreateFn = vi.fn().mockResolvedValue(makeMockStaffRole({ roleName: 'Test Role' })); - const mockDeleteAndReassignFn = vi.fn().mockResolvedValue(undefined); + const mockDeleteFn = vi.fn().mockResolvedValue(undefined); const mockQueryByIdFn = vi.fn().mockResolvedValue(makeMockStaffRole({ id: 'role1' })); const mockQueryByRoleNameFn = vi.fn().mockResolvedValue(makeMockStaffRole({ roleName: 'Test Role' })); vi.mocked(create).mockReturnValue(mockCreateFn); - vi.mocked(deleteAndReassign).mockReturnValue(mockDeleteAndReassignFn); + vi.mocked(deleteStaffRole).mockReturnValue(mockDeleteFn); vi.mocked(queryById).mockReturnValue(mockQueryByIdFn); vi.mocked(queryByRoleName).mockReturnValue(mockQueryByRoleNameFn); @@ -105,16 +105,16 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { }); }); - Scenario('Deleting and reassigning a staff role through the application service', ({ Given, When, Then }) => { + Scenario('Deleting a staff role through the application service', ({ Given, When, Then }) => { Given('a staff role application service', () => { expect(service).toBeDefined(); }); - When('I delete and reassign role "role1" to role "role2"', async () => { - await service.deleteAndReassign({ roleId: 'role1', reassignToRoleId: 'role2' }); + When('I delete role "role1"', async () => { + await service.delete({ roleId: 'role1', actorStaffUserId: 'actor-1' }); }); - Then('it should delegate to the deleteAndReassign function', () => { + Then('it should delegate to the delete function', () => { // If no error was thrown, the delegation worked expect(true).toBe(true); }); 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 73f72b3ec..aba540872 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 @@ -2,7 +2,7 @@ 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 { deleteStaffRole, type StaffRoleDeleteCommand, type StaffRoleDeleteResult } from './delete.ts'; import { list } from './list.ts'; import { queryById, type StaffRoleQueryByIdCommand } from './query-by-id.ts'; import { queryByRoleName, type StaffRoleQueryByRoleNameCommand } from './query-by-role-name.ts'; @@ -11,7 +11,7 @@ import { type StaffRoleUpdateCommand, update } from './update.ts'; export interface StaffRoleApplicationService { create: (command: StaffRoleCreateCommand) => Promise; createDefaultRoles: () => Promise; - deleteAndReassign: (command: StaffRoleDeleteAndReassignCommand) => Promise; + delete: (command: StaffRoleDeleteCommand) => Promise; list: () => Promise; queryById: (command: StaffRoleQueryByIdCommand) => Promise; queryByRoleName: (command: StaffRoleQueryByRoleNameCommand) => Promise; @@ -22,7 +22,7 @@ export const StaffRole = (dataSources: DataSources): StaffRoleApplicationService return { create: create(dataSources), createDefaultRoles: createDefaultRoles(dataSources), - deleteAndReassign: deleteAndReassign(dataSources), + delete: deleteStaffRole(dataSources), list: list(dataSources), queryById: queryById(dataSources), queryByRoleName: queryByRoleName(dataSources), diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/query-by-id.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/query-by-id.test.ts index 99f13799c..4cb5d4178 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/query-by-id.test.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/query-by-id.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 } from 'vitest'; import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; import { queryById } from './query-by-id.ts'; const test = { for: describeFeature }; @@ -93,6 +93,36 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { // Mock will be set up in When step }); + Scenario('Querying a logically deleted staff role by ID', ({ Given, When, Then }) => { + let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null; + + Given('a logically deleted staff role with id "507f1f77bcf86cd799439011" exists', () => { + // Mock will be set up in When step + }); + + When('I query for staff role with id "507f1f77bcf86cd799439011"', async () => { + const mockRole = makeMockStaffRole({ + deletion: { + actorStaffUserId: 'actor-1', + enterpriseAppRole: 'Staff.CaseManager', + deletedAt: new Date('2026-07-23T12:00:00.000Z'), + }, + }); + const mockRepo = makeMockRepo({ + getById: vi.fn().mockResolvedValue(mockRole), + }); + vi.mocked(dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction).mockImplementation(async (callback) => { + await callback(mockRepo); + }); + + result = await queryStaffRoleById({ roleId: '507f1f77bcf86cd799439011' }); + }); + + Then('it should return null', () => { + expect(result).toBeNull(); + }); + }); + When('I query for staff role with id "507f1f77bcf86cd799439011"', async () => { const mockRepo = makeMockRepo({ getById: vi.fn().mockRejectedValue(new Error('Not found')), diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/query-by-id.ts b/packages/ocom/application-services/src/contexts/user/staff-role/query-by-id.ts index bce97878b..c8a673495 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/query-by-id.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/query-by-id.ts @@ -22,6 +22,7 @@ export const queryById = (dataSources: DataSources) => { } throw error; } - return staffRole; + const foundRole = staffRole as Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null; + return foundRole?.deletion ? null : foundRole; }; }; diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/query-by-role-name.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/query-by-role-name.test.ts index 572745811..625c6a389 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/query-by-role-name.test.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/query-by-role-name.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 } from 'vitest'; import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; import { queryByRoleName } from './query-by-role-name.ts'; const test = { for: describeFeature }; @@ -93,6 +93,36 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { // Mock will be set up in When step }); + Scenario('Querying a logically deleted staff role by role name', ({ Given, When, Then }) => { + let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null; + + Given('a logically deleted staff role with name "Test Role" exists', () => { + // Mock will be set up in When step + }); + + When('I query for staff role with name "Test Role"', async () => { + const mockRole = makeMockStaffRole({ + deletion: { + actorStaffUserId: 'actor-1', + enterpriseAppRole: 'Staff.CaseManager', + deletedAt: new Date('2026-07-23T12:00:00.000Z'), + }, + }); + const mockRepo = makeMockRepo({ + getByRoleName: vi.fn().mockResolvedValue(mockRole), + }); + vi.mocked(dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction).mockImplementation(async (callback) => { + await callback(mockRepo); + }); + + result = await queryStaffRoleByName({ roleName: 'Test Role' }); + }); + + Then('it should return null', () => { + expect(result).toBeNull(); + }); + }); + When('I query for staff role with name "Test Role"', async () => { const mockRepo = makeMockRepo({ getByRoleName: vi.fn().mockRejectedValue(new Error('Not found')), diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/query-by-role-name.ts b/packages/ocom/application-services/src/contexts/user/staff-role/query-by-role-name.ts index e5b9de863..047e176ae 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/query-by-role-name.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/query-by-role-name.ts @@ -22,6 +22,7 @@ export const queryByRoleName = (dataSources: DataSources) => { } throw error; } - return staffRole; + const foundRole = staffRole as Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null; + return foundRole?.deletion ? null : foundRole; }; }; diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/update.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/update.test.ts index 87eae6d07..80b9d794c 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/update.test.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/update.test.ts @@ -35,6 +35,7 @@ interface MockStaffRoleInstance { roleName: string; enterpriseAppRole: string; isDefault: boolean; + deletion?: Domain.Contexts.User.StaffRole.StaffRoleDeletion | undefined; roleType: null; permissions: MockPermissions; createdAt: Date; @@ -147,6 +148,33 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { command = { roleId: 'role-002', roleName: 'Updated Role', enterpriseAppRole: 'Staff.UpdatedRole' }; }); + Scenario('Rejecting an update to a logically deleted role', ({ Given, When, Then, And }) => { + Given('a deleted staff role with id "role-deleted" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-deleted'); + roleInstance.deletion = { + actorStaffUserId: 'actor-1', + enterpriseAppRole: 'Staff.CaseManager', + deletedAt: new Date('2026-07-23T12:00:00.000Z'), + }; + dataSources = makeDataSources({ roleInstance }); + command = { roleId: 'role-deleted', roleName: 'Updated Role' }; + }); + When('I try to update roleId "role-deleted"', async () => { + try { + await update(dataSources)(command); + } catch (error) { + thrownError = error; + } + }); + Then('the deleted staff role should not be saved', () => { + const repo = dataSources._repo as { save: ReturnType }; + expect(repo.save).not.toHaveBeenCalled(); + }); + And('a not found error should be reported', () => { + expect((thrownError as Error).name).toBe('NotFoundError'); + }); + }); + When('I call update with roleId "role-002" and enterpriseAppRole "Staff.UpdatedRole"', async () => { try { result = await update(dataSources)(command); diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/update.ts b/packages/ocom/application-services/src/contexts/user/staff-role/update.ts index b68d15bb2..79b2d2012 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/update.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/update.ts @@ -1,3 +1,4 @@ +import { NotFoundError } from '@cellix/domain-seedwork/repository'; import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; import type { StaffRoleCommandPermissions } from './apply-permissions.ts'; @@ -16,6 +17,9 @@ export const update = (dataSources: DataSources) => { await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction(async (repository) => { const staffRole = await repository.getById(command.roleId); + if (staffRole.deletion) { + throw new NotFoundError(`StaffRole with id ${command.roleId} not found`); + } if (command.roleName !== undefined) { staffRole.roleName = command.roleName; } diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.test.ts b/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.test.ts index a9b6cf436..7b3ed4b7b 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.test.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.test.ts @@ -1,7 +1,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; -import type { Domain } from '@ocom/domain'; +import { NotFoundError } from '@cellix/domain-seedwork/repository'; +import { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; import { expect, vi } from 'vitest'; import { assignRole, type StaffUserAssignRoleCommand } from './assign-role.ts'; @@ -14,9 +15,25 @@ const feature = await loadFeature(path.resolve(__dirname, 'features/assign-role. interface MockStaffUserInstance extends Domain.Contexts.User.StaffUser.StaffUserEntityReference { role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + readonly roleId: string | undefined; } -function makeMockStaffRoleRef(id: string): Domain.Contexts.User.StaffRole.StaffRoleEntityReference { +interface MockStaffUserRepository { + get: ReturnType; + save: ReturnType; + setRoleIfCurrent: ReturnType; +} + +interface MockStaffRoleRepository { + getById: ReturnType; +} + +type TestDataSources = DataSources & { + _staffUserRepo: MockStaffUserRepository; + _staffRoleRepo: MockStaffRoleRepository; +}; + +function makeMockStaffRoleRef(id: string, deleted = false): Domain.Contexts.User.StaffRole.StaffRoleEntityReference { return { id, roleName: `role-${id}`, @@ -32,6 +49,15 @@ function makeMockStaffRoleRef(id: string): Domain.Contexts.User.StaffRole.StaffR createdAt: new Date(), updatedAt: new Date(), schemaVersion: '1.0', + ...(deleted + ? { + deletion: { + actorStaffUserId: 'actor-1', + enterpriseAppRole: 'Staff.CaseManager', + deletedAt: new Date('2026-07-23T12:00:00.000Z'), + }, + } + : {}), } as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference; } @@ -53,6 +79,9 @@ function makeMockStaffUserInstance(id: string): MockStaffUserInstance { set role(r: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined) { _role = r; }, + get roleId() { + return _role?.id; + }, requestRoleAssignment: vi.fn().mockImplementation((r: Domain.Contexts.User.StaffRole.StaffRoleEntityReference) => { _role = r; }), @@ -67,7 +96,12 @@ function makeDataSources(overrides: { staffRole?: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null; savedUser?: Domain.Contexts.User.StaffUser.StaffUserEntityReference; explicitUndefinedSave?: boolean; -}): DataSources & { _staffUserRepo: unknown; _staffRoleRepo: unknown } { + roleUnavailableAfterSave?: boolean; + roleDeletedAfterSave?: boolean; + roleVerificationError?: Error; + conditionalUpdateResult?: boolean; + previousRoleDeletedAfterRollback?: boolean; +}): TestDataSources { const staffUser = overrides.staffUser ?? makeMockStaffUserInstance('user-123'); const { staffRole } = overrides; const savedUser = overrides.explicitUndefinedSave ? undefined : (overrides.savedUser ?? staffUser); @@ -75,21 +109,54 @@ function makeDataSources(overrides: { const staffUserRepo = { get: vi.fn().mockResolvedValue(staffUser), save: vi.fn().mockResolvedValue(savedUser), - } as unknown as Domain.Contexts.User.StaffUser.StaffUserRepository; + setRoleIfCurrent: vi.fn().mockResolvedValue(overrides.conditionalUpdateResult ?? true), + }; + const getById = vi.fn(); + if (staffRole === null) { + getById.mockResolvedValue(null); + } else { + let targetRoleLookupCount = 0; + const roleLookupCounts = new Map(); + getById.mockImplementation((id: string) => { + if (id !== staffRole?.id) { + const lookupCount = (roleLookupCounts.get(id) ?? 0) + 1; + roleLookupCounts.set(id, lookupCount); + return Promise.resolve(makeMockStaffRoleRef(id, Boolean(overrides.previousRoleDeletedAfterRollback && lookupCount > 1))); + } + targetRoleLookupCount += 1; + if (targetRoleLookupCount > 1) { + if (overrides.roleDeletedAfterSave) { + return Promise.resolve(makeMockStaffRoleRef(id, true)); + } + if (overrides.roleUnavailableAfterSave) { + return Promise.reject(new NotFoundError(`StaffRole with id ${id} not found`)); + } + if (overrides.roleVerificationError) { + return Promise.reject(overrides.roleVerificationError); + } + } + return Promise.resolve(staffRole); + }); + } const staffRoleRepo = { - getById: staffRole === null ? vi.fn().mockResolvedValue(null) : vi.fn().mockResolvedValue(staffRole), - } as unknown as Domain.Contexts.User.StaffRole.StaffRoleRepository; + getById, + }; + + const userUnitOfWork = { + withScopedTransaction: vi.fn().mockImplementation(async (cb: (repo: typeof staffUserRepo) => Promise) => { + await cb(staffUserRepo); + }), + withTransaction: vi.fn().mockImplementation(async (_passport: unknown, cb: (repo: typeof staffUserRepo) => Promise) => { + await cb(staffUserRepo); + }), + }; return { domainDataSource: { User: { StaffUser: { - StaffUserUnitOfWork: { - withScopedTransaction: vi.fn().mockImplementation(async (cb: (repo: typeof staffUserRepo) => Promise) => { - await cb(staffUserRepo); - }), - }, + StaffUserUnitOfWork: userUnitOfWork, }, StaffRole: { StaffRoleUnitOfWork: { @@ -102,19 +169,20 @@ function makeDataSources(overrides: { }, _staffUserRepo: staffUserRepo, _staffRoleRepo: staffRoleRepo, - } as unknown as DataSources & { _staffUserRepo: unknown; _staffRoleRepo: unknown }; + } as unknown as TestDataSources; } // ─── Tests ──────────────────────────────────────────────────────────────────── test.for(feature, ({ Scenario, BeforeEachScenario }) => { - let dataSources: DataSources & { _staffUserRepo?: unknown; _staffRoleRepo?: unknown }; + let dataSources: TestDataSources; let command: StaffUserAssignRoleCommand; let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; let thrownError: unknown; let staffUser: MockStaffUserInstance; let staffRole: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null; BeforeEachScenario(() => { + vi.restoreAllMocks(); result = undefined; thrownError = undefined; staffUser = makeMockStaffUserInstance('user-123'); @@ -154,6 +222,11 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { expect(result).toBeDefined(); expect(result?.id).toBe('user-123'); }); + + And('the staff role should be validated before and after assignment', () => { + const repo = dataSources._staffRoleRepo as { getById: ReturnType }; + expect(repo.getById).toHaveBeenCalledTimes(2); + }); }); // ─── Role not found ─────────────────────────────────────────────────────── @@ -163,6 +236,29 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { staffUser = makeMockStaffUserInstance('user-123'); }); + Scenario('Rejects a logically deleted staff role before assignment', ({ Given, And, When, Then }) => { + Given('a staff user with id "user-123" exists', () => { + staffUser = makeMockStaffUserInstance('user-123'); + }); + And('a deleted staff role with id "role-456" exists', () => { + staffRole = makeMockStaffRoleRef('role-456', true); + dataSources = makeDataSources({ staffUser, staffRole }); + }); + When('I call assignRole with staffUserId "user-123" and roleId "role-456"', async () => { + try { + await assignRole(dataSources)(command); + } catch (error) { + thrownError = error; + } + }); + Then('the deleted role should not be assigned', () => { + expect(dataSources._staffUserRepo.save).not.toHaveBeenCalled(); + }); + And('it should throw an error with message containing "not available"', () => { + expect((thrownError as Error).message).toContain('not available'); + }); + }); + And('no staff role with id "role-999" exists in the repository', () => { staffRole = null; dataSources = makeDataSources({ staffUser, staffRole }); @@ -212,4 +308,199 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { expect((thrownError as Error).message).toBe('Unable to assign role to staff user'); }); }); + + Scenario('Rolls back when the role is deleted during assignment', ({ Given, And, When, Then }) => { + let reassignmentSpy: ReturnType; + Given('a staff user with id "user-123" is assigned to role "role-previous"', () => { + staffUser = makeMockStaffUserInstance('user-123'); + staffUser.role = makeMockStaffRoleRef('role-previous'); + }); + + And('role "role-456" is deleted after the staff user is saved', () => { + staffRole = makeMockStaffRoleRef('role-456'); + dataSources = makeDataSources({ staffUser, staffRole, roleDeletedAfterSave: true }); + reassignmentSpy = vi.spyOn(Domain.Services.User.StaffRoleDeletedReassignmentService, 'reassignStaffUsersToDefaultRole').mockResolvedValue(undefined); + }); + + When('I call assignRole with staffUserId "user-123" and roleId "role-456"', async () => { + try { + result = await assignRole(dataSources)(command); + } catch (error) { + thrownError = error; + } + }); + + Then('the committed role "role-456" should be conditionally replaced with "role-previous"', () => { + expect(dataSources._staffUserRepo.setRoleIfCurrent).toHaveBeenCalledWith( + expect.objectContaining({ + staffUserId: 'user-123', + expectedCurrentRoleId: 'role-456', + expectedUpdatedAt: staffUser.updatedAt, + replacementRoleId: 'role-previous', + }), + ); + }); + + And('no independent bulk reassignment should be started', () => { + expect(reassignmentSpy).not.toHaveBeenCalled(); + }); + + And('it should throw an error with message containing "no longer available"', () => { + expect((thrownError as Error).name).toBe('NotFoundError'); + expect((thrownError as Error).message).toContain('no longer available'); + }); + }); + + Scenario('Rolls back a committed assignment when role verification fails', ({ Given, And, When, Then }) => { + const verificationError = new Error('Database connection lost'); + Given('a staff user with id "user-123" is assigned to role "role-previous"', () => { + staffUser = makeMockStaffUserInstance('user-123'); + staffUser.role = makeMockStaffRoleRef('role-previous'); + }); + + And('role "role-456" verification fails after the staff user is saved', () => { + staffRole = makeMockStaffRoleRef('role-456'); + dataSources = makeDataSources({ staffUser, staffRole, roleVerificationError: verificationError }); + }); + + When('I call assignRole with staffUserId "user-123" and roleId "role-456"', async () => { + try { + result = await assignRole(dataSources)(command); + } catch (error) { + thrownError = error; + } + }); + + Then('the committed role "role-456" should be conditionally replaced with "role-previous"', () => { + expect(dataSources._staffUserRepo.setRoleIfCurrent).toHaveBeenCalledWith( + expect.objectContaining({ + staffUserId: 'user-123', + expectedCurrentRoleId: 'role-456', + replacementRoleId: 'role-previous', + activityType: 'ROLE_ASSIGNED', + }), + ); + }); + + And('it should report the role verification failure', () => { + expect(thrownError).toBe(verificationError); + }); + }); + + Scenario('Reprocesses a previous role deleted during rollback', ({ Given, And, When, Then }) => { + let recoverySpy: ReturnType; + Given('a staff user with id "user-123" is assigned to role "role-previous"', () => { + staffUser = makeMockStaffUserInstance('user-123'); + staffUser.role = makeMockStaffRoleRef('role-previous'); + }); + + And('role "role-456" is deleted after the staff user is saved', () => { + staffRole = makeMockStaffRoleRef('role-456'); + }); + + And('role "role-previous" is deleted while the failed assignment is rolled back', () => { + dataSources = makeDataSources({ staffUser, staffRole, roleDeletedAfterSave: true, previousRoleDeletedAfterRollback: true }); + recoverySpy = vi.spyOn(Domain.Services.User.StaffRoleDeletionRecoveryService, 'retryDeletedStaffRole').mockResolvedValue(true); + }); + + When('I call assignRole with staffUserId "user-123" and roleId "role-456"', async () => { + try { + result = await assignRole(dataSources)(command); + } catch (error) { + thrownError = error; + } + }); + + Then('the committed role "role-456" should be conditionally replaced with "role-previous"', () => { + expect(dataSources._staffUserRepo.setRoleIfCurrent).toHaveBeenCalledWith( + expect.objectContaining({ + staffUserId: 'user-123', + expectedCurrentRoleId: 'role-456', + replacementRoleId: 'role-previous', + }), + ); + }); + + And('the deletion event for role "role-previous" should be retried', () => { + expect(recoverySpy).toHaveBeenCalledWith('role-previous', dataSources.domainDataSource); + }); + + And('it should throw an error with message containing "no longer available"', () => { + expect((thrownError as Error).message).toContain('no longer available'); + }); + }); + + Scenario('Does not overwrite a newer assignment during rollback', ({ Given, And, When, Then }) => { + Given('a staff user with id "user-123" is assigned to role "role-previous"', () => { + staffUser = makeMockStaffUserInstance('user-123'); + staffUser.role = makeMockStaffRoleRef('role-previous'); + }); + + And('role "role-456" is deleted after the staff user is saved', () => { + staffRole = makeMockStaffRoleRef('role-456'); + }); + + And('the conditional rollback loses to a newer assignment', () => { + dataSources = makeDataSources({ staffUser, staffRole, roleDeletedAfterSave: true, conditionalUpdateResult: false }); + }); + + When('I call assignRole with staffUserId "user-123" and roleId "role-456"', async () => { + try { + result = await assignRole(dataSources)(command); + } catch (error) { + thrownError = error; + } + }); + + Then('the newer assignment should be preserved', () => { + expect(dataSources._staffUserRepo.setRoleIfCurrent).toHaveBeenCalledTimes(1); + expect(dataSources._staffUserRepo.save).toHaveBeenCalledTimes(1); + }); + + And('it should report that compensation did not complete', () => { + expect(thrownError).toBeInstanceOf(AggregateError); + expect((thrownError as Error).message).toContain('compensation did not complete'); + expect((thrownError as AggregateError).errors[0]).toBeInstanceOf(NotFoundError); + expect(((thrownError as AggregateError).errors[0] as Error).message).toContain('no longer available'); + }); + }); + + Scenario('Rolls an initially unassigned user back to no role', ({ Given, And, When, Then }) => { + const verificationError = new Error('Database connection lost'); + Given('a staff user with id "user-123" has no role', () => { + staffUser = makeMockStaffUserInstance('user-123'); + }); + + And('role "role-456" verification fails after the staff user is saved', () => { + staffRole = makeMockStaffRoleRef('role-456'); + dataSources = makeDataSources({ staffUser, staffRole, roleVerificationError: verificationError }); + }); + + When('I call assignRole with staffUserId "user-123" and roleId "role-456"', async () => { + try { + result = await assignRole(dataSources)(command); + } catch (error) { + thrownError = error; + } + }); + + Then('the committed role "role-456" should be conditionally removed', () => { + expect(dataSources._staffUserRepo.setRoleIfCurrent).toHaveBeenCalledWith( + expect.not.objectContaining({ + replacementRoleId: expect.anything(), + }), + ); + expect(dataSources._staffUserRepo.setRoleIfCurrent).toHaveBeenCalledWith( + expect.objectContaining({ + staffUserId: 'user-123', + expectedCurrentRoleId: 'role-456', + activityType: 'ROLE_REMOVED', + }), + ); + }); + + And('it should report the role verification failure', () => { + expect(thrownError).toBe(verificationError); + }); + }); }); diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.ts b/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.ts index 0961262fa..986ee90e4 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.ts @@ -1,4 +1,5 @@ -import type { Domain } from '@ocom/domain'; +import { NotFoundError } from '@cellix/domain-seedwork/repository'; +import { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; export interface StaffUserAssignRoleCommand { @@ -10,22 +11,21 @@ export interface StaffUserAssignRoleCommand { export const assignRole = (dataSources: DataSources) => { return async (command: StaffUserAssignRoleCommand): Promise => { let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; + let role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + let previousRoleId: string | undefined; - await dataSources.domainDataSource.User.StaffUser.StaffUserUnitOfWork.withScopedTransaction(async (staffUserRepo) => { - const staffUser = await staffUserRepo.get(command.staffUserId); + await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction(async (staffRoleRepo) => { + role = await staffRoleRepo.getById(command.roleId); + }); - let role!: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; - await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction(async (staffRoleRepo) => { - const foundRole = await staffRoleRepo.getById(command.roleId); - if (!foundRole) { - return; - } - role = foundRole; - }); + const roleToAssign = role; + if (!roleToAssign || roleToAssign.deletion) { + throw new NotFoundError(`StaffRole with id ${command.roleId} is not available for assignment`); + } - if (!role) { - throw new Error(`StaffRole with id ${command.roleId} not found`); - } + await dataSources.domainDataSource.User.StaffUser.StaffUserUnitOfWork.withScopedTransaction(async (staffUserRepo) => { + const staffUser = await staffUserRepo.get(command.staffUserId); + previousRoleId = staffUser.roleId; // Build a descriptive activity message including role name, target user and actor (fallback to IDs when names unavailable) let actorDisplayName = command.actorStaffUserId; @@ -33,21 +33,83 @@ export const assignRole = (dataSources: DataSources) => { const actor = await staffUserRepo.get(command.actorStaffUserId); if (actor?.displayName) actorDisplayName = actor.displayName; } catch (e) { - const error = e as Error; - if (error.name !== 'NotFoundError') { - throw error; + if (!(e instanceof NotFoundError)) { + throw e; } } - const roleName = role.roleName ?? command.roleId; + const roleName = roleToAssign.roleName ?? command.roleId; const description = `${roleName} assigned by ${actorDisplayName}`; - staffUser.requestRoleAssignment(role, description, command.actorStaffUserId); + staffUser.requestRoleAssignment(roleToAssign, description, command.actorStaffUserId); result = await staffUserRepo.save(staffUser); }); if (!result) { throw new Error('Unable to assign role to staff user'); } + const committedAssignment = result; + + try { + let verifiedRole: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction(async (staffRoleRepo) => { + verifiedRole = await staffRoleRepo.getById(command.roleId); + }); + if (!verifiedRole || verifiedRole.deletion) { + throw new NotFoundError(`StaffRole with id ${command.roleId} not found`); + } + } catch (error) { + const assignmentError = error instanceof NotFoundError ? new NotFoundError(`StaffRole with id ${command.roleId} is no longer available for assignment`) : error; + + try { + const roleIdToRestore = previousRoleId; + if (roleIdToRestore) { + await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction(async (staffRoleRepo) => { + const previousRole = await staffRoleRepo.getById(roleIdToRestore); + if (previousRole.deletion) { + throw new NotFoundError(`StaffRole with id ${roleIdToRestore} is not available for assignment`); + } + }); + } + + const systemPassport = Domain.PassportFactory.forSystem({ + canManageStaffRolesAndPermissions: true, + }); + let rolledBack = false; + await dataSources.domainDataSource.User.StaffUser.StaffUserUnitOfWork.withTransaction(systemPassport, async (staffUserRepo) => { + rolledBack = await staffUserRepo.setRoleIfCurrent({ + staffUserId: command.staffUserId, + expectedCurrentRoleId: command.roleId, + expectedUpdatedAt: committedAssignment.updatedAt, + ...(roleIdToRestore ? { replacementRoleId: roleIdToRestore } : {}), + activityType: roleIdToRestore + ? Domain.Contexts.User.StaffUser.StaffUserActivityLogValueObjects.ActivityTypeCodes.RoleAssigned + : Domain.Contexts.User.StaffUser.StaffUserActivityLogValueObjects.ActivityTypeCodes.RoleRemoved, + activityDescription: roleIdToRestore ? `Restored previous role after assignment verification failed` : `Removed role after assignment verification failed`, + activityByStaffUserId: command.actorStaffUserId, + }); + }); + if (!rolledBack) { + throw new Error(`Staff user ${command.staffUserId} changed after role assignment and could not be safely restored`); + } + + if (roleIdToRestore) { + let restoredRole: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction(async (staffRoleRepo) => { + restoredRole = await staffRoleRepo.getById(roleIdToRestore); + }); + if (!restoredRole) { + throw new NotFoundError(`StaffRole with id ${roleIdToRestore} is not available for assignment`); + } + if (restoredRole.deletion) { + await Domain.Services.User.StaffRoleDeletionRecoveryService.retryDeletedStaffRole(roleIdToRestore, dataSources.domainDataSource); + } + } + } catch (rollbackError) { + throw new AggregateError([assignmentError, rollbackError], `Role assignment for staff user ${command.staffUserId} committed, verification failed, and compensation did not complete`); + } + + throw assignmentError; + } - return result; + return committedAssignment; }; }; diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/features/assign-role.feature b/packages/ocom/application-services/src/contexts/user/staff-user/features/assign-role.feature index 0a105591d..8ef78d815 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-user/features/assign-role.feature +++ b/packages/ocom/application-services/src/contexts/user/staff-user/features/assign-role.feature @@ -6,6 +6,7 @@ Feature: Assign role to staff user When I call assignRole with staffUserId "user-123" and roleId "role-456" Then the staff user should be saved with the role assigned And the result should be the updated staff user + And the staff role should be validated before and after assignment Scenario: Throws an error when the staff role does not exist Given a staff user with id "user-123" exists @@ -13,9 +14,55 @@ Feature: Assign role to staff user When I call assignRole with staffUserId "user-123" and roleId "role-999" Then it should throw an error with message containing "role-999" + Scenario: Rejects a logically deleted staff role before assignment + Given a staff user with id "user-123" exists + And a deleted staff role with id "role-456" exists + When I call assignRole with staffUserId "user-123" and roleId "role-456" + Then the deleted role should not be assigned + And it should throw an error with message containing "not available" + Scenario: Throws an error when the unit of work returns no result Given a staff user with id "user-123" exists And a staff role with id "role-456" exists And saving the staff user returns undefined When I call assignRole with staffUserId "user-123" and roleId "role-456" Then it should throw an error with message "Unable to assign role to staff user" + + Scenario: Rolls back when the role is deleted during assignment + Given a staff user with id "user-123" is assigned to role "role-previous" + And role "role-456" is deleted after the staff user is saved + When I call assignRole with staffUserId "user-123" and roleId "role-456" + Then the committed role "role-456" should be conditionally replaced with "role-previous" + And no independent bulk reassignment should be started + And it should throw an error with message containing "no longer available" + + Scenario: Rolls back a committed assignment when role verification fails + Given a staff user with id "user-123" is assigned to role "role-previous" + And role "role-456" verification fails after the staff user is saved + When I call assignRole with staffUserId "user-123" and roleId "role-456" + Then the committed role "role-456" should be conditionally replaced with "role-previous" + And it should report the role verification failure + + Scenario: Reprocesses a previous role deleted during rollback + Given a staff user with id "user-123" is assigned to role "role-previous" + And role "role-456" is deleted after the staff user is saved + And role "role-previous" is deleted while the failed assignment is rolled back + When I call assignRole with staffUserId "user-123" and roleId "role-456" + Then the committed role "role-456" should be conditionally replaced with "role-previous" + And the deletion event for role "role-previous" should be retried + And it should throw an error with message containing "no longer available" + + Scenario: Does not overwrite a newer assignment during rollback + Given a staff user with id "user-123" is assigned to role "role-previous" + And role "role-456" is deleted after the staff user is saved + And the conditional rollback loses to a newer assignment + When I call assignRole with staffUserId "user-123" and roleId "role-456" + Then the newer assignment should be preserved + And it should report that compensation did not complete + + Scenario: Rolls an initially unassigned user back to no role + Given a staff user with id "user-123" has no role + And role "role-456" verification fails after the staff user is saved + When I call assignRole with staffUserId "user-123" and roleId "role-456" + Then the committed role "role-456" should be conditionally removed + And it should report the role verification failure 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 6099c443f..c056e3325 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 @@ -91,6 +91,13 @@ export interface StaffRolePermissions { propertyPermissions: StaffRolePropertyPermissions; } +export interface StaffRoleDeletion { + actorStaffUserId: string; + enterpriseAppRole: string; + deletedAt: Date; + reassignmentCompletedAt?: Date | undefined; +} + export interface StaffRole extends Role { permissions: StaffRolePermissions; @@ -98,8 +105,21 @@ export interface StaffRole extends Role { enterpriseAppRole?: string; roleType?: string; isDefault: boolean; + deletion?: StaffRoleDeletion | undefined; } +const StaffRoleDeletionSchema = new Schema( + { + actorStaffUserId: { type: String, required: true }, + enterpriseAppRole: { type: String, required: true, enum: StaffEnterpriseAppRoles }, + deletedAt: { type: Date, required: true }, + reassignmentCompletedAt: { type: Date, required: false }, + }, + { + _id: false, + }, +); + const StaffRoleSchema = new Schema, StaffRole>( { permissions: { @@ -181,9 +201,16 @@ const StaffRoleSchema = new Schema, StaffRole>( enum: StaffEnterpriseAppRoles, }, isDefault: { type: Boolean, required: true, default: false }, + deletion: { + type: StaffRoleDeletionSchema, + required: false, + default: undefined, + }, }, roleOptions, -).index({ roleName: 1 }, { unique: true }); +) + .index({ roleName: 1 }, { unique: true }) + .index({ 'deletion.deletedAt': 1 }, { sparse: true }); export const StaffRoleModelName: string = 'staff-user-role'; diff --git a/packages/ocom/data-sources-mongoose-models/src/models/user/staff-user.model.ts b/packages/ocom/data-sources-mongoose-models/src/models/user/staff-user.model.ts index 8420b3f9b..a5336bad4 100644 --- a/packages/ocom/data-sources-mongoose-models/src/models/user/staff-user.model.ts +++ b/packages/ocom/data-sources-mongoose-models/src/models/user/staff-user.model.ts @@ -57,6 +57,7 @@ const StaffUserSchema = new Schema, StaffUser>( type: Schema.Types.ObjectId, ref: StaffRole.StaffRoleModelName, required: false, + index: true, }, firstName: { type: String, 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 b751e6feb..6dfbcf56e 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 @@ -46,24 +46,40 @@ Feature: StaffRole When I try to set isDefault to true Then a PermissionError should be thrown - # deleteAndReassignTo - Scenario: Deleting a non-default staff role with permission - Given a StaffRole aggregate that is not deleted and is not default, with permission to manage staff roles and permissions - When I call deleteAndReassignTo with a valid StaffRoleEntityReference - Then the staff role should be marked as deleted - And a RoleDeletedReassignEvent should be added to integration events - - Scenario: Deleting a non-default staff role without permission - Given a StaffRole aggregate that is not deleted and is not default, without permission to manage staff roles and permissions - When I try to call deleteAndReassignTo with a valid StaffRoleEntityReference + # requestDelete + Scenario: Logically deleting a non-default staff role with the remove-role permission + Given a StaffRole aggregate that is not deleted and is not default, with permission to remove staff roles + When I call requestDelete + Then the staff role should contain a durable deletion tombstone + And a StaffRoleDeletedEvent should be added to integration events + + Scenario: Deleting a non-default staff role without the remove-role permission + Given a StaffRole aggregate that is not deleted and is not default, without permission to remove staff roles + When I try to call requestDelete Then a PermissionError should be thrown - And no RoleDeletedReassignEvent should be emitted + And no StaffRoleDeletedEvent should be emitted + + Scenario: Deleting the role currently assigned to the actor + Given a non-default StaffRole aggregate assigned to the actor, with permission to remove staff roles + When the actor tries to delete their currently assigned role + Then a PermissionError should be thrown for deleting the currently assigned role + And no StaffRoleDeletedEvent should be emitted for the actor's role Scenario: Deleting a default staff role Given a StaffRole aggregate that is default - When I try to call deleteAndReassignTo with a valid StaffRoleEntityReference + When I try to call requestDelete Then a PermissionError should be thrown - And no RoleDeletedReassignEvent should be emitted + And no StaffRoleDeletedEvent should be emitted + + Scenario: Retrying an already deleted staff role + Given a StaffRole aggregate that already has a deletion tombstone, with permission to remove staff roles + When I call requestDelete again + Then another StaffRoleDeletedEvent should be emitted using the original deletion actor + + Scenario: Recovering an interrupted staff role deletion + Given a StaffRole aggregate that already has a deletion tombstone and a system passport + When I retry the deleted staff role + Then a StaffRoleDeletedEvent should be emitted using the persisted deletion payload # permissions (delegation) Scenario: Accessing permissions entity 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 aceb90ac2..c7e23dd6c 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 @@ -1,5 +1,6 @@ export type { StaffRoleRepository } from './staff-role.repository.ts'; export type { + StaffRoleDeletion, StaffRoleEntityReference, StaffRoleProps, } from './staff-role.ts'; diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.test.ts index 7d07d8431..475ef1853 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.test.ts @@ -72,6 +72,8 @@ function makeMockRepository(passport: Passport): StaffRoleRepository new StaffRole(makeBaseProps({ id }), passport)), getByRoleName: vi.fn(async (roleName: string) => new StaffRole(makeBaseProps({ roleName }), passport)), getDefaultRoleByEnterpriseAppRole: vi.fn(async (enterpriseAppRole: string) => new StaffRole(makeBaseProps({ enterpriseAppRole, isDefault: true }), passport)), + getDeletedRoles: vi.fn(async () => []), + markReassignmentCompleted: vi.fn(async () => undefined), } satisfies StaffRoleRepository; } 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 ff2cf93bc..f3c8f5e57 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 @@ -9,4 +9,6 @@ export interface StaffRoleRepository extends Repos getById(id: string): Promise>; getByRoleName(roleName: string): Promise>; getDefaultRoleByEnterpriseAppRole(enterpriseAppRole: string): Promise>; + getDeletedRoles(): Promise[]>; + markReassignmentCompleted(roleId: string, completedAt: Date): 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 bf2553dd2..2f37c0639 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 @@ -3,20 +3,20 @@ 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 { RoleDeletedReassignEvent } from '../../../events/types/role-deleted-reassign.ts'; +import { StaffRoleDeletedEvent } from '../../../events/types/staff-role-deleted.ts'; import type { Passport } from '../../passport.ts'; -import { StaffRole, type StaffRoleEntityReference, type StaffRoleProps } from './staff-role.ts'; +import { StaffRole, type StaffRoleProps } from './staff-role.ts'; import { StaffRolePermissions } from './staff-role-permissions.ts'; const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role.feature')); -function makePassport(canManageStaffRolesAndPermissions = true, isSystemAccount = false): Passport { +function makePassport(canManageStaffRolesAndPermissions = true, isSystemAccount = false, canRemoveRole = false): Passport { return vi.mocked({ user: { forStaffRole: vi.fn(() => ({ - determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean; isSystemAccount: boolean }) => boolean) => fn({ canManageStaffRolesAndPermissions, isSystemAccount }), + determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean; isSystemAccount: boolean; canRemoveRole: boolean }) => boolean) => fn({ canManageStaffRolesAndPermissions, isSystemAccount, canRemoveRole }), })), }, } as unknown as Passport); @@ -197,77 +197,145 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); }); - // deleteAndReassignTo - Scenario('Deleting a non-default staff role with permission', ({ Given, When, Then, And }) => { + // requestDelete + Scenario('Logically deleting a non-default staff role with the remove-role permission', ({ Given, When, Then, And }) => { let deletedRole: StaffRole; - Given('a StaffRole aggregate that is not deleted and is not default, with permission to manage staff roles and permissions', () => { - passport = makePassport(true, false); - deletedRole = new StaffRole(makeBaseProps({ isDefault: false }), passport); - }); - When('I call deleteAndReassignTo with a valid StaffRoleEntityReference', () => { - deletedRole.deleteAndReassignTo({ - id: 'role-2', - } as StaffRoleEntityReference); - }); - Then('the staff role should be marked as deleted', () => { - expect(deletedRole.isDeleted).toBe(true); - }); - And('a RoleDeletedReassignEvent should be added to integration events', () => { - const event = getIntegrationEvent(deletedRole.getIntegrationEvents(), RoleDeletedReassignEvent); + Given('a StaffRole aggregate that is not deleted and is not default, with permission to remove staff roles', () => { + passport = makePassport(false, false, true); + deletedRole = new StaffRole(makeBaseProps({ isDefault: false, enterpriseAppRole: 'Staff.CaseManager' }), passport); + }); + When('I call requestDelete', () => { + deletedRole.requestDelete('actor-1'); + }); + Then('the staff role should contain a durable deletion tombstone', () => { + expect(deletedRole.isDeleted).toBe(false); + expect(deletedRole.deletion).toEqual({ + actorStaffUserId: 'actor-1', + enterpriseAppRole: 'Staff.CaseManager', + deletedAt: expect.any(Date), + }); + expect(deletedRole.roleName).toMatch(/^Deleted role-1 \d+$/); + expect(deletedRole.enterpriseAppRole).toBe('Staff.CaseManager'); + }); + And('a StaffRoleDeletedEvent should be added to integration events', () => { + const event = getIntegrationEvent(deletedRole.getIntegrationEvents(), StaffRoleDeletedEvent); expect(event).toBeDefined(); - expect(event).toBeInstanceOf(RoleDeletedReassignEvent); + expect(event).toBeInstanceOf(StaffRoleDeletedEvent); expect(event?.payload.deletedRoleId).toBe('role-1'); - expect(event?.payload.newRoleId).toBe('role-2'); + expect(event?.payload.enterpriseAppRole).toBe('Staff.CaseManager'); + expect(event?.payload.actorStaffUserId).toBe('actor-1'); }); }); - Scenario('Deleting a non-default staff role without permission', ({ Given, When, Then, And }) => { + Scenario('Deleting a non-default staff role without the remove-role permission', ({ Given, When, Then, And }) => { let deletedRole: StaffRole; let deletingRoleWithoutPermission: () => void; - Given('a StaffRole aggregate that is not deleted and is not default, without permission to manage staff roles and permissions', () => { - passport = makePassport(false, false); + Given('a StaffRole aggregate that is not deleted and is not default, without permission to remove staff roles', () => { + passport = makePassport(true, false, false); deletedRole = new StaffRole(makeBaseProps({ isDefault: false }), passport); }); - When('I try to call deleteAndReassignTo with a valid StaffRoleEntityReference', () => { + When('I try to call requestDelete', () => { deletingRoleWithoutPermission = () => { - deletedRole.deleteAndReassignTo({ - id: 'role-2', - } as StaffRoleEntityReference); + deletedRole.requestDelete('actor-1'); }; }); Then('a PermissionError should be thrown', () => { expect(deletingRoleWithoutPermission).toThrow(PermissionError); expect(deletingRoleWithoutPermission).toThrow('You do not have permission to delete this role'); }); - And('no RoleDeletedReassignEvent should be emitted', () => { - const event = getIntegrationEvent(deletedRole.getIntegrationEvents(), RoleDeletedReassignEvent); + And('no StaffRoleDeletedEvent should be emitted', () => { + const event = getIntegrationEvent(deletedRole.getIntegrationEvents(), StaffRoleDeletedEvent); expect(event).toBeUndefined(); }); }); + Scenario('Deleting the role currently assigned to the actor', ({ Given, When, Then, And }) => { + let assignedRole: StaffRole; + let deletingOwnRole: () => void; + Given('a non-default StaffRole aggregate assigned to the actor, with permission to remove staff roles', () => { + passport = makePassport(false, false, true); + assignedRole = new StaffRole(makeBaseProps({ id: 'role-1', isDefault: false }), passport); + }); + When('the actor tries to delete their currently assigned role', () => { + deletingOwnRole = () => { + assignedRole.requestDelete('actor-1', 'role-1'); + }; + }); + Then('a PermissionError should be thrown for deleting the currently assigned role', () => { + expect(deletingOwnRole).toThrow(PermissionError); + expect(deletingOwnRole).toThrow('You cannot delete the role currently assigned to you'); + }); + And("no StaffRoleDeletedEvent should be emitted for the actor's role", () => { + expect(getIntegrationEvent(assignedRole.getIntegrationEvents(), StaffRoleDeletedEvent)).toBeUndefined(); + }); + }); + Scenario('Deleting a default staff role', ({ Given, When, Then, And }) => { let defaultRole: StaffRole; let deletingDefaultRole: () => void; Given('a StaffRole aggregate that is default', () => { - passport = makePassport(true, false); + passport = makePassport(true, false, true); defaultRole = new StaffRole(makeBaseProps({ isDefault: true }), passport); }); - When('I try to call deleteAndReassignTo with a valid StaffRoleEntityReference', () => { + When('I try to call requestDelete', () => { deletingDefaultRole = () => { - defaultRole.deleteAndReassignTo({ - id: 'role-2', - } as StaffRoleEntityReference); + defaultRole.requestDelete('actor-1'); }; }); Then('a PermissionError should be thrown', () => { expect(deletingDefaultRole).toThrow(PermissionError); + expect(deletingDefaultRole).toThrow('You cannot delete a default staff role'); }); - And('no RoleDeletedReassignEvent should be emitted', () => { - const event = getIntegrationEvent(defaultRole.getIntegrationEvents(), RoleDeletedReassignEvent); + And('no StaffRoleDeletedEvent should be emitted', () => { + const event = getIntegrationEvent(defaultRole.getIntegrationEvents(), StaffRoleDeletedEvent); expect(event).toBeUndefined(); }); }); + Scenario('Retrying an already deleted staff role', ({ Given, When, Then }) => { + let deletedRole: StaffRole; + Given('a StaffRole aggregate that already has a deletion tombstone, with permission to remove staff roles', () => { + passport = makePassport(false, false, true); + deletedRole = new StaffRole(makeBaseProps({ isDefault: false }), passport); + deletedRole.requestDelete('actor-1'); + deletedRole.clearIntegrationEvents(); + }); + When('I call requestDelete again', () => { + deletedRole.requestDelete('actor-2'); + }); + Then('another StaffRoleDeletedEvent should be emitted using the original deletion actor', () => { + const event = getIntegrationEvent(deletedRole.getIntegrationEvents(), StaffRoleDeletedEvent); + expect(event?.payload.actorStaffUserId).toBe('actor-1'); + expect(event?.payload.enterpriseAppRole).toBe(''); + }); + }); + + Scenario('Recovering an interrupted staff role deletion', ({ Given, When, Then }) => { + let deletedRole: StaffRole; + Given('a StaffRole aggregate that already has a deletion tombstone and a system passport', () => { + const props = makeBaseProps({ + isDefault: false, + deletion: { + actorStaffUserId: 'actor-1', + enterpriseAppRole: 'Staff.CaseManager', + deletedAt: new Date('2026-07-23T12:00:00.000Z'), + }, + }); + deletedRole = new StaffRole(props, makePassport(false, true)); + }); + When('I retry the deleted staff role', () => { + deletedRole.retryDelete(); + }); + Then('a StaffRoleDeletedEvent should be emitted using the persisted deletion payload', () => { + const event = getIntegrationEvent(deletedRole.getIntegrationEvents(), StaffRoleDeletedEvent); + expect(event?.payload).toEqual({ + deletedRoleId: 'role-1', + enterpriseAppRole: 'Staff.CaseManager', + actorStaffUserId: 'actor-1', + }); + }); + }); + // permissions (delegation) Scenario('Accessing permissions entity', ({ Given, When, Then }) => { let permissions: StaffRolePermissions; @@ -515,4 +583,54 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { expect(role.permissions.userPermissions.canManageUsers).toBe(true); }); }); + + Scenario('Getting default role names', ({ When, Then, And }) => { + let names: string[]; + When('I call getDefaultRoleNames', () => { + names = StaffRole.getDefaultRoleNames(); + }); + Then('the result should contain "Default.CaseManager"', () => { + expect(names).toContain('Default.CaseManager'); + }); + And('the result should contain "Default.ServiceLineOwner"', () => { + expect(names).toContain('Default.ServiceLineOwner'); + }); + And('the result should contain "Default.Finance"', () => { + expect(names).toContain('Default.Finance'); + }); + And('the result should contain "Default.TechAdmin"', () => { + expect(names).toContain('Default.TechAdmin'); + }); + And('the result should have exactly 4 names', () => { + expect(names).toHaveLength(4); + }); + }); + + Scenario('Creating a default tech admin role', ({ When, Then, And }) => { + let role: StaffRole; + When('I create a default tech admin staff role', () => { + role = StaffRole.getNewDefaultTechAdminInstance(makeFactoryProps(), makePassport(true, true)); + }); + Then('the roleName should be "Default Tech Admin"', () => { + expect(role.roleName).toBe('Default Tech Admin'); + }); + And('the enterpriseAppRole should be "Staff.TechAdmin"', () => { + expect(role.enterpriseAppRole).toBe('Staff.TechAdmin'); + }); + And('the tech admin role should allow managing communities', () => { + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + And('the tech admin role should allow managing staff roles and permissions', () => { + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + }); + And('the tech admin role should allow managing finance', () => { + expect(role.permissions.financePermissions.canManageFinance).toBe(true); + }); + And('the tech admin role should allow managing tech admin', () => { + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(true); + }); + And('the tech admin role should allow managing users', () => { + expect(role.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 1bec5d72e..ddc897720 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,16 +1,24 @@ 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 { StaffRoleDeletedEvent, type StaffRoleDeletedProps } from '../../../events/types/staff-role-deleted.ts'; import type { Passport } from '../../passport.ts'; import type { UserVisa } from '../user.visa.ts'; import * as ValueObjects from './staff-role.value-objects.ts'; import { StaffRolePermissions, type StaffRolePermissionsEntityReference, type StaffRolePermissionsProps } from './staff-role-permissions.ts'; +export interface StaffRoleDeletion { + readonly actorStaffUserId: string; + readonly enterpriseAppRole: string; + readonly deletedAt: Date; + readonly reassignmentCompletedAt?: Date | undefined; +} + export interface StaffRoleProps extends DomainEntityProps { roleName: string; isDefault: boolean; enterpriseAppRole: string; + deletion?: StaffRoleDeletion | undefined; readonly permissions: StaffRolePermissionsProps; readonly roleType: string | null; readonly createdAt: Date; @@ -125,17 +133,58 @@ export class StaffRole extends AggregateRoot permissions.canManageStaffRolesAndPermissions)) { + if (!this.visa.determineIf((permissions) => permissions.canRemoveRole || permissions.isSystemAccount)) { throw new PermissionError('You do not have permission to delete this role'); } - super.isDeleted = true; - this.addIntegrationEvent(RoleDeletedReassignEvent, { + if (actorStaffRoleId === this.id) { + throw new PermissionError('You cannot delete the role currently assigned to you'); + } + if (!this.props.deletion) { + const enterpriseAppRole = this.props.enterpriseAppRole; + const deletedAt = new Date(); + this.props.deletion = { + actorStaffUserId, + enterpriseAppRole, + deletedAt, + }; + this.props.roleName = `Deleted ${this.id} ${deletedAt.getTime()}`; + this.props.enterpriseAppRole = enterpriseAppRole; + } + this.raiseDeletedEvent(); + } + + public retryDelete(): void { + if (!this.visa.determineIf((permissions) => permissions.isSystemAccount)) { + throw new PermissionError('Only the system account can retry staff role deletion'); + } + if (!this.props.deletion) { + throw new Error(`Staff role ${this.id} is not deleted`); + } + this.raiseDeletedEvent(); + } + + private raiseDeletedEvent(): void { + const deletion = this.props.deletion; + if (!deletion) { + throw new Error(`Staff role ${this.id} is not deleted`); + } + this.addIntegrationEvent(StaffRoleDeletedEvent, { deletedRoleId: this.props.id, - newRoleId: roleRef.id, + enterpriseAppRole: deletion.enterpriseAppRole, + actorStaffUserId: deletion.actorStaffUserId, }); } @@ -170,6 +219,9 @@ export class StaffRole extends AggregateRoot extends Repository> { delete(id: string): Promise; + getAssignedRoleIds(roleIds: string[]): Promise; + getAssignedUserIdsToRoleBatch(roleId: string, limit: number): Promise; getByExternalId(externalId: string): Promise>; + getAllAssignedToRole(roleId: string): Promise[]>; getNewInstance(externalId: string, firstName: string, lastName: string, email: string): Promise>; + setRoleIfCurrent(command: SetStaffUserRoleIfCurrentCommand): Promise; } diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.ts b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.ts index 6dcfdbf44..ec197ea45 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.ts @@ -12,6 +12,7 @@ import * as ActivityLogValueObjects from './staff-user-activity-log.value-object export interface StaffUserProps extends DomainEntityProps { readonly role?: StaffRoleProps; + readonly roleId?: string | undefined; setRoleRef: (role: StaffRoleEntityReference | undefined) => void; firstName: string; lastName: string; @@ -28,8 +29,9 @@ export interface StaffUserProps extends DomainEntityProps { activityLog: PropArray; } -export interface StaffUserEntityReference extends Readonly> { +export interface StaffUserEntityReference extends Readonly> { readonly role: StaffRoleEntityReference | undefined; + readonly roleId?: string | undefined; readonly activityLog: ReadonlyArray; } @@ -109,6 +111,9 @@ export class StaffUser extends AggregateRoot {} diff --git a/packages/ocom/domain/src/domain/iam/member/contexts/member.user.end-user.visa.ts b/packages/ocom/domain/src/domain/iam/member/contexts/member.user.end-user.visa.ts index 29ec97f0e..bcc78a9e0 100644 --- a/packages/ocom/domain/src/domain/iam/member/contexts/member.user.end-user.visa.ts +++ b/packages/ocom/domain/src/domain/iam/member/contexts/member.user.end-user.visa.ts @@ -16,6 +16,7 @@ export class MemberUserEndUserVisa implemen const updatedPermissions: UserDomainPermissions = { canManageEndUsers: false, canManageStaffRolesAndPermissions: false, + canRemoveRole: false, canManageStaffUsers: false, canManageVendorUsers: false, isEditingOwnAccount: this.member.accounts.some((account) => account.user.id === this.root.id), diff --git a/packages/ocom/domain/src/domain/iam/member/contexts/member.user.staff-role.visa.ts b/packages/ocom/domain/src/domain/iam/member/contexts/member.user.staff-role.visa.ts index acce8a495..c28d7f3d8 100644 --- a/packages/ocom/domain/src/domain/iam/member/contexts/member.user.staff-role.visa.ts +++ b/packages/ocom/domain/src/domain/iam/member/contexts/member.user.staff-role.visa.ts @@ -15,6 +15,7 @@ export class MemberUserStaffRoleVisa impl const updatedPermissions: UserDomainPermissions = { canManageEndUsers: false, canManageStaffRolesAndPermissions: false, + canRemoveRole: false, canManageStaffUsers: false, canManageVendorUsers: false, isEditingOwnAccount: false, diff --git a/packages/ocom/domain/src/domain/iam/member/contexts/member.user.staff-user.visa.ts b/packages/ocom/domain/src/domain/iam/member/contexts/member.user.staff-user.visa.ts index 653c0d362..645690fc6 100644 --- a/packages/ocom/domain/src/domain/iam/member/contexts/member.user.staff-user.visa.ts +++ b/packages/ocom/domain/src/domain/iam/member/contexts/member.user.staff-user.visa.ts @@ -15,6 +15,7 @@ export class MemberUserStaffUserVisa impl const updatedPermissions: UserDomainPermissions = { canManageEndUsers: false, canManageStaffRolesAndPermissions: false, + canRemoveRole: false, canManageStaffUsers: false, canManageVendorUsers: false, isEditingOwnAccount: false, diff --git a/packages/ocom/domain/src/domain/iam/member/contexts/member.user.vendor-user.visa.ts b/packages/ocom/domain/src/domain/iam/member/contexts/member.user.vendor-user.visa.ts index b57a60253..5c70b1d3b 100644 --- a/packages/ocom/domain/src/domain/iam/member/contexts/member.user.vendor-user.visa.ts +++ b/packages/ocom/domain/src/domain/iam/member/contexts/member.user.vendor-user.visa.ts @@ -15,6 +15,7 @@ export class MemberUserVendorUserVisa im const updatedPermissions: UserDomainPermissions = { canManageEndUsers: false, canManageStaffRolesAndPermissions: false, + canRemoveRole: false, canManageStaffUsers: false, canManageVendorUsers: false, isEditingOwnAccount: false, diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/features/staff-user.user.passport.feature b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/features/staff-user.user.passport.feature index 47e2d8262..cbee0fbe3 100644 --- a/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/features/staff-user.user.passport.feature +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/features/staff-user.user.passport.feature @@ -40,6 +40,16 @@ Feature: StaffUserUserPassport Then determineIf should return true for canManageStaffRolesAndPermissions And determineIf should return false for isEditingOwnAccount + Scenario: forStaffRole maps canRemoveRole from the staff user's role permissions + Given a StaffUserUserPassport for a staff user whose role grants canRemoveRole + When I call forStaffRole with any StaffRoleEntityReference + Then determineIf should return true for canRemoveRole + + Scenario: forStaffRole denies canRemoveRole when the staff user's role does not grant it + Given a StaffUserUserPassport for a staff user whose role does not grant canRemoveRole + When I call forStaffRole with any StaffRoleEntityReference + Then determineIf should return false for canRemoveRole + Scenario: forVendorUser returns a visa where canManageStaffRolesAndPermissions is true Given a StaffUserUserPassport for the staff user When I call forVendorUser with any VendorUserEntityReference diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.test.ts b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.test.ts index 7c67ea067..8428c0e69 100644 --- a/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.test.ts +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.test.ts @@ -14,7 +14,7 @@ const feature = await loadFeature(path.resolve(__dirname, 'features/staff-user.u // ─── Helpers ───────────────────────────────────────────────────────────────── -function makeStaffUser(externalId = 'ext-1', canManageStaffRolesAndPermissions = true): StaffUserEntityReference { +function makeStaffUser(externalId = 'ext-1', canManageStaffRolesAndPermissions = true, canRemoveRole = false): StaffUserEntityReference { return { id: 'staff-1', externalId, @@ -23,6 +23,9 @@ function makeStaffUser(externalId = 'ext-1', canManageStaffRolesAndPermissions = communityPermissions: { canManageStaffRolesAndPermissions, }, + staffRolePermissions: { + canRemoveRole, + }, }, }, } as unknown as StaffUserEntityReference; @@ -156,6 +159,32 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); }); + Scenario("forStaffRole maps canRemoveRole from the staff user's role permissions", ({ Given, When, Then }) => { + let visa: ReturnType; + Given('a StaffUserUserPassport for a staff user whose role grants canRemoveRole', () => { + passport = new StaffUserUserPassport(makeStaffUser('ext-1', true, true)); + }); + When('I call forStaffRole with any StaffRoleEntityReference', () => { + visa = passport.forStaffRole({} as StaffRoleEntityReference); + }); + Then('determineIf should return true for canRemoveRole', () => { + expect(visa.determineIf((p) => p.canRemoveRole)).toBe(true); + }); + }); + + Scenario("forStaffRole denies canRemoveRole when the staff user's role does not grant it", ({ Given, When, Then }) => { + let visa: ReturnType; + Given('a StaffUserUserPassport for a staff user whose role does not grant canRemoveRole', () => { + passport = new StaffUserUserPassport(makeStaffUser('ext-1', true, false)); + }); + When('I call forStaffRole with any StaffRoleEntityReference', () => { + visa = passport.forStaffRole({} as StaffRoleEntityReference); + }); + Then('determineIf should return false for canRemoveRole', () => { + expect(visa.determineIf((p) => p.canRemoveRole)).toBe(false); + }); + }); + // ─── forVendorUser ──────────────────────────────────────────────────────── Scenario('forVendorUser returns a visa where canManageStaffRolesAndPermissions is true', ({ Given, When, Then, And }) => { 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 index 7375b9941..2deb49883 100644 --- 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 @@ -33,6 +33,7 @@ export class StaffUserUserPassport extends StaffUserPassportBase implements User return { canManageEndUsers: false, canManageStaffRolesAndPermissions, + canRemoveRole: this._user.role?.permissions.staffRolePermissions.canRemoveRole ?? false, canManageStaffUsers: canManageStaffRolesAndPermissions, canManageVendorUsers: false, isEditingOwnAccount: root !== undefined && root.externalId === this._user.externalId, diff --git a/packages/ocom/domain/src/domain/services/features/index.feature b/packages/ocom/domain/src/domain/services/features/index.feature index f6f802cad..f9c30d8a5 100644 --- a/packages/ocom/domain/src/domain/services/features/index.feature +++ b/packages/ocom/domain/src/domain/services/features/index.feature @@ -3,4 +3,9 @@ Feature: Services Index Scenario: Exporting Community services Given the services index module When I import the Community export - Then it should export the Community services object \ No newline at end of file + Then it should export the Community services object + + Scenario: Exporting User services + Given the services index module + When I import the User export + Then it should export the User services object \ No newline at end of file diff --git a/packages/ocom/domain/src/domain/services/index.test.ts b/packages/ocom/domain/src/domain/services/index.test.ts index 0b81fe9fb..26a8ebc0a 100644 --- a/packages/ocom/domain/src/domain/services/index.test.ts +++ b/packages/ocom/domain/src/domain/services/index.test.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import { expect } from 'vitest'; -import { Community } from './index.ts'; +import { Community, User } from './index.ts'; const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -25,4 +25,20 @@ test.for(feature, ({ Scenario }) => { expect(typeof communityExport).toBe('object'); }); }); + + Scenario('Exporting User services', ({ Given, When, Then }) => { + let userExport: typeof User; + Given('the services index module', () => { + // Module is already imported + }); + + When('I import the User export', () => { + userExport = User; + }); + + Then('it should export the User services object', () => { + expect(userExport).toBeDefined(); + expect(typeof userExport).toBe('object'); + }); + }); }); diff --git a/packages/ocom/domain/src/domain/services/index.ts b/packages/ocom/domain/src/domain/services/index.ts index d8d17c1ba..97b509231 100644 --- a/packages/ocom/domain/src/domain/services/index.ts +++ b/packages/ocom/domain/src/domain/services/index.ts @@ -1 +1,2 @@ export { Community } from './community/index.ts'; +export { User } from './user/index.ts'; diff --git a/packages/ocom/domain/src/domain/services/user/features/index.feature b/packages/ocom/domain/src/domain/services/user/features/index.feature new file mode 100644 index 000000000..b64203c64 --- /dev/null +++ b/packages/ocom/domain/src/domain/services/user/features/index.feature @@ -0,0 +1,11 @@ +Feature: User Services Index + + Scenario: Exporting StaffRoleDeletedReassignmentService + Given the user services index module + When I import the User export + Then it should expose a StaffRoleDeletedReassignmentService instance + + Scenario: Exporting StaffRoleDeletionRecoveryService + Given the user services index module + When I import the User export + Then it should expose a StaffRoleDeletionRecoveryService instance diff --git a/packages/ocom/domain/src/domain/services/user/features/staff-role-deleted-reassignment.service.feature b/packages/ocom/domain/src/domain/services/user/features/staff-role-deleted-reassignment.service.feature new file mode 100644 index 000000000..3bb6d154d --- /dev/null +++ b/packages/ocom/domain/src/domain/services/user/features/staff-role-deleted-reassignment.service.feature @@ -0,0 +1,53 @@ +Feature: StaffRoleDeletedReassignmentService + + Background: + Given a StaffRoleDeletedReassignmentService instance + And a valid domainDataSource with staff role and staff user repositories + And a default staff role with id "default-role-1" and enterpriseAppRole "Staff.CaseManager" + + Scenario: Reassigning staff users assigned to the deleted role to the matching default role + Given two staff users assigned to the deleted role "deleted-role-1" + When I call reassignStaffUsersToDefaultRole for role "deleted-role-1" with enterpriseAppRole "Staff.CaseManager" + Then each assigned staff user should be conditionally reassigned to the default role "default-role-1" + And each conditional update should record the initiating actor + And the deleted role reassignment should be marked complete + + Scenario: Reassignment does not overwrite a newer concurrent role assignment + Given a candidate staff user whose role changes before the conditional update + When I call reassignStaffUsersToDefaultRole for role "deleted-role-1" with enterpriseAppRole "Staff.CaseManager" + Then the conditional update should be allowed to report no change + + Scenario: Reassigning a large role in committed batches + Given twenty-five staff users assigned to the deleted role "deleted-role-1" + When I call reassignStaffUsersToDefaultRole for role "deleted-role-1" with enterpriseAppRole "Staff.CaseManager" + Then the staff users should be reassigned in three bounded transactions + And all twenty-five conditional role updates should be attempted + And the deleted role reassignment should be marked complete + + Scenario: A later batch failure preserves earlier reassignment progress + Given fifteen staff users assigned to the deleted role "deleted-role-1" + And the second reassignment batch will fail + When I try to call reassignStaffUsersToDefaultRole for role "deleted-role-1" with enterpriseAppRole "Staff.CaseManager" + Then ten conditional role updates should have completed before the failure + And five staff users should remain for recovery + And the deleted role reassignment should not be marked complete + And the batch failure should be rethrown + + Scenario: No staff users are assigned to the deleted role + Given no staff users assigned to the deleted role "deleted-role-1" + When I call reassignStaffUsersToDefaultRole for role "deleted-role-1" with enterpriseAppRole "Staff.CaseManager" + Then no conditional role update should be attempted + And the deleted role reassignment should be marked complete + + Scenario: Failing when no default role matches the deleted role's enterpriseAppRole + Given no default staff role exists for enterpriseAppRole "Staff.Unmatched" + When I try to call reassignStaffUsersToDefaultRole for role "deleted-role-1" with enterpriseAppRole "Staff.Unmatched" + Then the missing default role failure should be logged and rethrown + And no conditional role update should be attempted + And the deleted role reassignment should not be marked complete + + Scenario: Surfacing a reassignment completion marker failure + Given no staff users assigned to the deleted role "deleted-role-1" + And marking the deleted role reassignment complete will fail + When I try to call reassignStaffUsersToDefaultRole for role "deleted-role-1" with enterpriseAppRole "Staff.CaseManager" + Then the completion marker failure should be rethrown diff --git a/packages/ocom/domain/src/domain/services/user/features/staff-role-deletion-recovery.service.feature b/packages/ocom/domain/src/domain/services/user/features/staff-role-deletion-recovery.service.feature new file mode 100644 index 000000000..bf71b8ce9 --- /dev/null +++ b/packages/ocom/domain/src/domain/services/user/features/staff-role-deletion-recovery.service.feature @@ -0,0 +1,38 @@ +Feature: StaffRoleDeletionRecoveryService + + Scenario: Retrying persisted deleted staff roles + Given two staff role tombstones are persisted + When I retry deleted staff role events + Then each tombstone should re-raise its deletion event under a system passport + And the number of retried roles should be returned + + Scenario: No deleted staff roles require recovery + Given no staff role tombstones are persisted + When I retry deleted staff role events + Then no staff role should be saved for event dispatch + And zero retried roles should be returned + + Scenario: Skipping a completed tombstone with no dangling staff users + Given completed staff role tombstone "role-1" is persisted + And no staff user references role "role-1" + When I retry deleted staff role events + Then no staff role should be saved for event dispatch + And zero retried roles should be returned + + Scenario: Retrying a completed tombstone with a dangling staff user + Given completed staff role tombstone "role-1" is persisted + And a staff user references role "role-1" + When I retry deleted staff role events + Then tombstone "role-1" should re-raise its deletion event + And one retried role should be returned + + Scenario: Retrying one deleted staff role by id + Given staff role tombstone "role-2" is persisted + When I retry deleted staff role "role-2" + Then only tombstone "role-2" should re-raise its deletion event + And the targeted role should be reported as retried + + Scenario: Surfacing a recovery event processing failure + Given a deleted staff role event fails during recovery + When I try to retry deleted staff role events + Then the recovery processing failure should be rethrown diff --git a/packages/ocom/domain/src/domain/services/user/index.test.ts b/packages/ocom/domain/src/domain/services/user/index.test.ts new file mode 100644 index 000000000..39de1a7a3 --- /dev/null +++ b/packages/ocom/domain/src/domain/services/user/index.test.ts @@ -0,0 +1,45 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { expect } from 'vitest'; +import { User } from './index.ts'; +import { StaffRoleDeletedReassignmentService } from './staff-role-deleted-reassignment.service.ts'; +import { StaffRoleDeletionRecoveryService } from './staff-role-deletion-recovery.service.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/index.feature')); + +test.for(feature, ({ Scenario }) => { + let userExport: typeof User; + + Scenario('Exporting StaffRoleDeletedReassignmentService', ({ Given, When, Then }) => { + Given('the user services index module', () => { + // Module is already imported + }); + + When('I import the User export', () => { + userExport = User; + }); + + Then('it should expose a StaffRoleDeletedReassignmentService instance', () => { + expect(userExport).toBeDefined(); + expect(userExport.StaffRoleDeletedReassignmentService).toBeInstanceOf(StaffRoleDeletedReassignmentService); + }); + }); + + Scenario('Exporting StaffRoleDeletionRecoveryService', ({ Given, When, Then }) => { + Given('the user services index module', () => { + // Module is already imported + }); + + When('I import the User export', () => { + userExport = User; + }); + + Then('it should expose a StaffRoleDeletionRecoveryService instance', () => { + expect(userExport).toBeDefined(); + expect(userExport.StaffRoleDeletionRecoveryService).toBeInstanceOf(StaffRoleDeletionRecoveryService); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/services/user/index.ts b/packages/ocom/domain/src/domain/services/user/index.ts new file mode 100644 index 000000000..c5cb8e0e7 --- /dev/null +++ b/packages/ocom/domain/src/domain/services/user/index.ts @@ -0,0 +1,7 @@ +import { StaffRoleDeletedReassignmentService } from './staff-role-deleted-reassignment.service.ts'; +import { StaffRoleDeletionRecoveryService } from './staff-role-deletion-recovery.service.ts'; + +export const User = { + StaffRoleDeletedReassignmentService: new StaffRoleDeletedReassignmentService(), + StaffRoleDeletionRecoveryService: new StaffRoleDeletionRecoveryService(), +}; diff --git a/packages/ocom/domain/src/domain/services/user/staff-role-deleted-reassignment.service.test.ts b/packages/ocom/domain/src/domain/services/user/staff-role-deleted-reassignment.service.test.ts new file mode 100644 index 000000000..eda2b1c3a --- /dev/null +++ b/packages/ocom/domain/src/domain/services/user/staff-role-deleted-reassignment.service.test.ts @@ -0,0 +1,278 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { expect, type MockedFunction, vi } from 'vitest'; +import type { DomainDataSource } from '../../../index.ts'; +import type { Passport } from '../../contexts/passport.ts'; +import type * as StaffRole from '../../contexts/user/staff-role/index.ts'; +import type * as StaffUser from '../../contexts/user/staff-user/index.ts'; +import { StaffRoleDeletedReassignmentService } from './staff-role-deleted-reassignment.service.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role-deleted-reassignment.service.feature')); + +interface MockStaffUser { + id: string; +} + +function makeMockStaffUser(id: string): MockStaffUser { + return { + id, + }; +} + +test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { + let service: StaffRoleDeletedReassignmentService; + let mockDomainDataSource: DomainDataSource; + let mockDefaultRole: StaffRole.StaffRoleEntityReference; + let mockStaffRoleRepo: { + getDefaultRoleByEnterpriseAppRole: MockedFunction<(enterpriseAppRole: string) => Promise>; + markReassignmentCompleted: MockedFunction<(roleId: string, completedAt: Date) => Promise>; + }; + let mockStaffUserRepo: { + getAssignedUserIdsToRoleBatch: MockedFunction<(roleId: string, limit: number) => Promise>; + setRoleIfCurrent: MockedFunction<(command: StaffUser.SetStaffUserRoleIfCurrentCommand) => Promise>; + }; + let assignedStaffUsers: MockStaffUser[]; + let staffUserWithTransaction: ReturnType; + let runStaffUserTransaction: (passport: Passport, fn: (repo: typeof mockStaffUserRepo) => Promise) => Promise; + let capturedPassport: Passport | undefined; + let thrownError: Error | null = null; + let consoleErrorSpy: ReturnType; + + BeforeEachScenario(() => { + thrownError = null; + capturedPassport = undefined; + assignedStaffUsers = []; + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + mockDefaultRole = { + id: 'default-role-1', + roleName: 'Default Case Manager', + enterpriseAppRole: 'Staff.CaseManager', + isDefault: true, + } as unknown as StaffRole.StaffRoleEntityReference; + + mockStaffRoleRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn(), + markReassignmentCompleted: vi.fn(async () => undefined), + }; + mockStaffUserRepo = { + getAssignedUserIdsToRoleBatch: vi.fn((_roleId, limit) => Promise.resolve(assignedStaffUsers.slice(0, limit).map(({ id }) => id))), + setRoleIfCurrent: vi.fn((command) => { + assignedStaffUsers = assignedStaffUsers.filter(({ id }) => id !== command.staffUserId); + return Promise.resolve(true); + }), + }; + runStaffUserTransaction = async (passport, fn) => { + capturedPassport = passport; + await fn(mockStaffUserRepo); + }; + staffUserWithTransaction = vi.fn(runStaffUserTransaction); + mockStaffRoleRepo.getDefaultRoleByEnterpriseAppRole.mockImplementation((enterpriseAppRole: string) => { + if (enterpriseAppRole === mockDefaultRole.enterpriseAppRole) { + return Promise.resolve(mockDefaultRole); + } + return Promise.reject(new Error(`StaffRole with enterpriseAppRole ${enterpriseAppRole} not found`)); + }); + + mockDomainDataSource = { + User: { + StaffRole: { + StaffRoleUnitOfWork: { + withTransaction: vi.fn(async (passport: Passport, fn: (repo: typeof mockStaffRoleRepo) => Promise) => { + capturedPassport = passport; + await fn(mockStaffRoleRepo); + }), + withScopedTransaction: vi.fn(async (fn: (repo: typeof mockStaffRoleRepo) => Promise) => { + await fn(mockStaffRoleRepo); + }), + }, + }, + StaffUser: { + StaffUserUnitOfWork: { + withTransaction: staffUserWithTransaction, + withScopedTransaction: vi.fn(), + }, + }, + }, + } as unknown as DomainDataSource; + + service = new StaffRoleDeletedReassignmentService(); + }); + + Background(({ Given, And }) => { + Given('a StaffRoleDeletedReassignmentService instance', () => { + // Created in BeforeEachScenario + }); + And('a valid domainDataSource with staff role and staff user repositories', () => { + // Created in BeforeEachScenario + }); + And('a default staff role with id "default-role-1" and enterpriseAppRole "Staff.CaseManager"', () => { + // Created in BeforeEachScenario + }); + }); + + Scenario('Reassigning staff users assigned to the deleted role to the matching default role', ({ Given, When, Then, And }) => { + Given('two staff users assigned to the deleted role "deleted-role-1"', () => { + assignedStaffUsers = [makeMockStaffUser('staff-user-1'), makeMockStaffUser('staff-user-2')]; + }); + When('I call reassignStaffUsersToDefaultRole for role "deleted-role-1" with enterpriseAppRole "Staff.CaseManager"', async () => { + await service.reassignStaffUsersToDefaultRole('deleted-role-1', 'Staff.CaseManager', 'actor-1', mockDomainDataSource); + }); + Then('each assigned staff user should be conditionally reassigned to the default role "default-role-1"', () => { + expect(mockStaffUserRepo.getAssignedUserIdsToRoleBatch).toHaveBeenCalledWith('deleted-role-1', 10); + for (const staffUserId of ['staff-user-1', 'staff-user-2']) { + expect(mockStaffUserRepo.setRoleIfCurrent).toHaveBeenCalledWith( + expect.objectContaining({ + staffUserId, + expectedCurrentRoleId: 'deleted-role-1', + replacementRoleId: 'default-role-1', + }), + ); + } + expect(capturedPassport).toBeDefined(); + }); + And('each conditional update should record the initiating actor', () => { + expect(mockStaffUserRepo.setRoleIfCurrent).toHaveBeenCalledTimes(2); + for (const call of mockStaffUserRepo.setRoleIfCurrent.mock.calls) { + expect(call[0]).toEqual( + expect.objectContaining({ + activityType: 'ROLE_ASSIGNED', + activityByStaffUserId: 'actor-1', + }), + ); + } + }); + And('the deleted role reassignment should be marked complete', () => { + expect(mockStaffRoleRepo.markReassignmentCompleted).toHaveBeenCalledWith('deleted-role-1', expect.any(Date)); + }); + }); + + Scenario('Reassignment does not overwrite a newer concurrent role assignment', ({ Given, When, Then }) => { + Given('a candidate staff user whose role changes before the conditional update', () => { + assignedStaffUsers = [makeMockStaffUser('staff-user-1')]; + mockStaffUserRepo.setRoleIfCurrent.mockImplementation((command) => { + assignedStaffUsers = assignedStaffUsers.filter(({ id }) => id !== command.staffUserId); + return Promise.resolve(false); + }); + }); + When('I call reassignStaffUsersToDefaultRole for role "deleted-role-1" with enterpriseAppRole "Staff.CaseManager"', async () => { + await service.reassignStaffUsersToDefaultRole('deleted-role-1', 'Staff.CaseManager', 'actor-1', mockDomainDataSource); + }); + Then('the conditional update should be allowed to report no change', () => { + expect(mockStaffUserRepo.setRoleIfCurrent).toHaveBeenCalledTimes(1); + expect(mockStaffUserRepo.setRoleIfCurrent).toHaveResolvedWith(false); + }); + }); + + Scenario('Reassigning a large role in committed batches', ({ Given, When, Then, And }) => { + Given('twenty-five staff users assigned to the deleted role "deleted-role-1"', () => { + assignedStaffUsers = Array.from({ length: 25 }, (_, index) => makeMockStaffUser(`staff-user-${index + 1}`)); + }); + When('I call reassignStaffUsersToDefaultRole for role "deleted-role-1" with enterpriseAppRole "Staff.CaseManager"', async () => { + await service.reassignStaffUsersToDefaultRole('deleted-role-1', 'Staff.CaseManager', 'actor-1', mockDomainDataSource); + }); + Then('the staff users should be reassigned in three bounded transactions', () => { + expect(staffUserWithTransaction).toHaveBeenCalledTimes(3); + expect(mockStaffUserRepo.getAssignedUserIdsToRoleBatch).toHaveBeenCalledTimes(3); + expect(mockStaffUserRepo.getAssignedUserIdsToRoleBatch).toHaveBeenCalledWith('deleted-role-1', 10); + }); + And('all twenty-five conditional role updates should be attempted', () => { + expect(mockStaffUserRepo.setRoleIfCurrent).toHaveBeenCalledTimes(25); + expect(assignedStaffUsers).toEqual([]); + }); + And('the deleted role reassignment should be marked complete', () => { + expect(mockStaffRoleRepo.markReassignmentCompleted).toHaveBeenCalledWith('deleted-role-1', expect.any(Date)); + }); + }); + + Scenario('A later batch failure preserves earlier reassignment progress', ({ Given, And, When, Then }) => { + const batchError = new Error('batch failed'); + Given('fifteen staff users assigned to the deleted role "deleted-role-1"', () => { + assignedStaffUsers = Array.from({ length: 15 }, (_, index) => makeMockStaffUser(`staff-user-${index + 1}`)); + }); + And('the second reassignment batch will fail', () => { + staffUserWithTransaction.mockImplementationOnce(runStaffUserTransaction).mockRejectedValueOnce(batchError); + }); + When('I try to call reassignStaffUsersToDefaultRole for role "deleted-role-1" with enterpriseAppRole "Staff.CaseManager"', async () => { + try { + await service.reassignStaffUsersToDefaultRole('deleted-role-1', 'Staff.CaseManager', 'actor-1', mockDomainDataSource); + } catch (error) { + thrownError = error as Error; + } + }); + Then('ten conditional role updates should have completed before the failure', () => { + expect(mockStaffUserRepo.setRoleIfCurrent).toHaveBeenCalledTimes(10); + }); + And('five staff users should remain for recovery', () => { + expect(assignedStaffUsers).toHaveLength(5); + }); + And('the deleted role reassignment should not be marked complete', () => { + expect(mockStaffRoleRepo.markReassignmentCompleted).not.toHaveBeenCalled(); + }); + And('the batch failure should be rethrown', () => { + expect(thrownError).toBe(batchError); + }); + }); + + Scenario('No staff users are assigned to the deleted role', ({ Given, When, Then, And }) => { + Given('no staff users assigned to the deleted role "deleted-role-1"', () => { + assignedStaffUsers = []; + }); + When('I call reassignStaffUsersToDefaultRole for role "deleted-role-1" with enterpriseAppRole "Staff.CaseManager"', async () => { + await service.reassignStaffUsersToDefaultRole('deleted-role-1', 'Staff.CaseManager', 'actor-1', mockDomainDataSource); + }); + Then('no conditional role update should be attempted', () => { + expect(mockStaffUserRepo.setRoleIfCurrent).not.toHaveBeenCalled(); + }); + And('the deleted role reassignment should be marked complete', () => { + expect(mockStaffRoleRepo.markReassignmentCompleted).toHaveBeenCalledWith('deleted-role-1', expect.any(Date)); + }); + }); + + Scenario("Failing when no default role matches the deleted role's enterpriseAppRole", ({ Given, When, Then, And }) => { + Given('no default staff role exists for enterpriseAppRole "Staff.Unmatched"', () => { + assignedStaffUsers = [makeMockStaffUser('staff-user-1')]; + }); + When('I try to call reassignStaffUsersToDefaultRole for role "deleted-role-1" with enterpriseAppRole "Staff.Unmatched"', async () => { + try { + await service.reassignStaffUsersToDefaultRole('deleted-role-1', 'Staff.Unmatched', 'actor-1', mockDomainDataSource); + } catch (error) { + thrownError = error as Error; + } + }); + Then('the missing default role failure should be logged and rethrown', () => { + expect(thrownError).not.toBeNull(); + expect(thrownError?.message).toContain('Staff.Unmatched'); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Staff.Unmatched'), expect.any(Error)); + }); + And('no conditional role update should be attempted', () => { + expect(mockStaffUserRepo.setRoleIfCurrent).not.toHaveBeenCalled(); + }); + And('the deleted role reassignment should not be marked complete', () => { + expect(mockStaffRoleRepo.markReassignmentCompleted).not.toHaveBeenCalled(); + }); + }); + + Scenario('Surfacing a reassignment completion marker failure', ({ Given, And, When, Then }) => { + const markerError = new Error('marker write failed'); + Given('no staff users assigned to the deleted role "deleted-role-1"', () => { + assignedStaffUsers = []; + }); + And('marking the deleted role reassignment complete will fail', () => { + mockStaffRoleRepo.markReassignmentCompleted.mockRejectedValue(markerError); + }); + When('I try to call reassignStaffUsersToDefaultRole for role "deleted-role-1" with enterpriseAppRole "Staff.CaseManager"', async () => { + try { + await service.reassignStaffUsersToDefaultRole('deleted-role-1', 'Staff.CaseManager', 'actor-1', mockDomainDataSource); + } catch (error) { + thrownError = error as Error; + } + }); + Then('the completion marker failure should be rethrown', () => { + expect(thrownError).toBe(markerError); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/services/user/staff-role-deleted-reassignment.service.ts b/packages/ocom/domain/src/domain/services/user/staff-role-deleted-reassignment.service.ts new file mode 100644 index 000000000..7616a53fd --- /dev/null +++ b/packages/ocom/domain/src/domain/services/user/staff-role-deleted-reassignment.service.ts @@ -0,0 +1,64 @@ +import type { DomainDataSource } from '../../../index.ts'; +import { PassportFactory } from '../../contexts/passport.ts'; +import type * as StaffRole from '../../contexts/user/staff-role/index.ts'; +import * as StaffUser from '../../contexts/user/staff-user/index.ts'; + +// Cosmos DB for MongoDB limits transactions to five seconds. +const REASSIGNMENT_BATCH_SIZE = 10; + +export class StaffRoleDeletedReassignmentService { + /** + * Reassigns every staff user assigned to a deleted staff role to the + * default staff role whose enterpriseAppRole matches the deleted role's. + * + * Idempotent: staff users already assigned to the matching default role + * are skipped, so re-processing the same event causes no changes. + * + * @throws when no default staff role matches the deleted role's + * enterpriseAppRole — the failure is logged and rethrown so the event is + * observed as a processing failure instead of silently stranding users. + */ + async reassignStaffUsersToDefaultRole(deletedRoleId: string, enterpriseAppRole: string, actorStaffUserId: string, domainDataSource: DomainDataSource): Promise { + let defaultRole: StaffRole.StaffRoleEntityReference | null = null; + try { + await domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction(async (repo) => { + defaultRole = await repo.getDefaultRoleByEnterpriseAppRole(enterpriseAppRole); + }); + } catch (error) { + console.error(`No default staff role found for enterprise app role "${enterpriseAppRole}" while reassigning staff users from deleted role ${deletedRoleId}`, error); + throw error; + } + if (!defaultRole) { + const message = `No default staff role found for enterprise app role "${enterpriseAppRole}" while reassigning staff users from deleted role ${deletedRoleId}`; + console.error(message); + throw new Error(message); + } + const matchingDefaultRole = defaultRole as StaffRole.StaffRoleEntityReference; + + const systemPassport = PassportFactory.forSystem({ + canManageStaffRolesAndPermissions: true, + isSystemAccount: true, + }); + let assignedStaffUserCount: number; + do { + assignedStaffUserCount = 0; + await domainDataSource.User.StaffUser.StaffUserUnitOfWork.withTransaction(systemPassport, async (repo) => { + const assignedStaffUserIds = await repo.getAssignedUserIdsToRoleBatch(deletedRoleId, REASSIGNMENT_BATCH_SIZE); + assignedStaffUserCount = assignedStaffUserIds.length; + for (const staffUserId of assignedStaffUserIds) { + await repo.setRoleIfCurrent({ + staffUserId, + expectedCurrentRoleId: deletedRoleId, + replacementRoleId: matchingDefaultRole.id, + activityType: StaffUser.StaffUserActivityLogValueObjects.ActivityTypeCodes.RoleAssigned, + activityDescription: `Reassigned to default role ${matchingDefaultRole.roleName} after previous role was deleted`, + activityByStaffUserId: actorStaffUserId, + }); + } + }); + } while (assignedStaffUserCount === REASSIGNMENT_BATCH_SIZE); + await domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withTransaction(systemPassport, async (repo) => { + await repo.markReassignmentCompleted(deletedRoleId, new Date()); + }); + } +} diff --git a/packages/ocom/domain/src/domain/services/user/staff-role-deletion-recovery.service.test.ts b/packages/ocom/domain/src/domain/services/user/staff-role-deletion-recovery.service.test.ts new file mode 100644 index 000000000..f746547b2 --- /dev/null +++ b/packages/ocom/domain/src/domain/services/user/staff-role-deletion-recovery.service.test.ts @@ -0,0 +1,230 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { expect, vi } from 'vitest'; +import type { DomainDataSource } from '../../../index.ts'; +import { type Passport, PassportFactory } from '../../contexts/passport.ts'; +import { StaffRole, type StaffRoleProps } from '../../contexts/user/staff-role/index.ts'; +import { StaffRoleDeletedEvent } from '../../events/types/staff-role-deleted.ts'; +import { StaffRoleDeletionRecoveryService } from './staff-role-deletion-recovery.service.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role-deletion-recovery.service.feature')); + +function makeMockDeletedRole(id: string) { + return { + id, + retryDelete: vi.fn(), + } as unknown as StaffRole; +} + +function makePersistedDeletedRole(id: string, passport: Passport, reassignmentCompleted = false) { + return new StaffRole( + { + id, + roleName: `Deleted ${id}`, + isDefault: false, + enterpriseAppRole: 'Staff.CaseManager', + deletion: { + actorStaffUserId: 'actor-1', + enterpriseAppRole: 'Staff.CaseManager', + deletedAt: new Date('2026-07-23T12:00:00.000Z'), + ...(reassignmentCompleted ? { reassignmentCompletedAt: new Date('2026-07-23T12:01:00.000Z') } : {}), + }, + permissions: {} as StaffRoleProps['permissions'], + roleType: 'staff-user-role', + createdAt: new Date('2026-07-23T11:00:00.000Z'), + updatedAt: new Date('2026-07-23T12:00:00.000Z'), + schemaVersion: '1.0.0', + }, + passport, + ); +} + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let service: StaffRoleDeletionRecoveryService; + let deletedRoles: StaffRole[]; + let persistedDeletedRoleIds: string[]; + let completedDeletedRoleIds: string[]; + let assignedRoleIds: string[]; + let savedRoles: StaffRole[]; + let capturedPassport: Passport | undefined; + let domainDataSource: DomainDataSource; + let result: number | undefined; + let targetedResult: boolean | undefined; + let thrownError: unknown; + let transactionError: Error | undefined; + let forSystemSpy: ReturnType; + + BeforeEachScenario(() => { + vi.restoreAllMocks(); + service = new StaffRoleDeletionRecoveryService(); + deletedRoles = []; + persistedDeletedRoleIds = []; + completedDeletedRoleIds = []; + assignedRoleIds = []; + savedRoles = []; + capturedPassport = undefined; + result = undefined; + targetedResult = undefined; + thrownError = undefined; + transactionError = undefined; + forSystemSpy = vi.spyOn(PassportFactory, 'forSystem'); + + const repository = { + getDeletedRoles: vi.fn(() => Promise.resolve(deletedRoles)), + getById: vi.fn((id: string) => Promise.resolve(deletedRoles.find((role) => role.id === id) ?? makePersistedDeletedRole(id, capturedPassport as Passport, completedDeletedRoleIds.includes(id)))), + save: vi.fn((role: StaffRole) => { + savedRoles.push(role); + return Promise.resolve(role); + }), + }; + domainDataSource = { + User: { + StaffRole: { + StaffRoleUnitOfWork: { + withTransaction: vi.fn(async (passport: Passport, callback: (repo: typeof repository) => Promise) => { + capturedPassport = passport; + if (persistedDeletedRoleIds.length > 0) { + deletedRoles = persistedDeletedRoleIds.map((id) => makePersistedDeletedRole(id, passport, completedDeletedRoleIds.includes(id))); + } + await callback(repository); + if (transactionError) { + throw transactionError; + } + }), + withScopedTransaction: vi.fn(), + }, + }, + StaffUser: { + StaffUserUnitOfWork: { + withTransaction: vi.fn(async (_passport: Passport, callback: (repo: { getAssignedRoleIds: (roleIds: string[]) => Promise }) => Promise) => { + await callback({ + getAssignedRoleIds: vi.fn(async (roleIds: string[]) => assignedRoleIds.filter((roleId) => roleIds.includes(roleId))), + }); + }), + withScopedTransaction: vi.fn(), + }, + }, + }, + } as unknown as DomainDataSource; + }); + + Scenario('Retrying persisted deleted staff roles', ({ Given, When, Then, And }) => { + Given('two staff role tombstones are persisted', () => { + persistedDeletedRoleIds = ['role-1', 'role-2']; + }); + When('I retry deleted staff role events', async () => { + result = await service.retryDeletedStaffRoles(domainDataSource); + }); + Then('each tombstone should re-raise its deletion event under a system passport', () => { + expect(capturedPassport).toBeDefined(); + expect(forSystemSpy).toHaveBeenCalledWith( + expect.objectContaining({ + isSystemAccount: true, + }), + ); + for (const role of deletedRoles) { + const events = role.getIntegrationEvents(); + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(StaffRoleDeletedEvent); + } + expect(savedRoles).toEqual(deletedRoles); + }); + And('the number of retried roles should be returned', () => { + expect(result).toBe(2); + }); + }); + + Scenario('No deleted staff roles require recovery', ({ Given, When, Then, And }) => { + Given('no staff role tombstones are persisted', () => { + deletedRoles = []; + }); + When('I retry deleted staff role events', async () => { + result = await service.retryDeletedStaffRoles(domainDataSource); + }); + Then('no staff role should be saved for event dispatch', () => { + expect(savedRoles).toEqual([]); + }); + And('zero retried roles should be returned', () => { + expect(result).toBe(0); + }); + }); + + Scenario('Skipping a completed tombstone with no dangling staff users', ({ Given, And, When, Then }) => { + Given('completed staff role tombstone "role-1" is persisted', () => { + persistedDeletedRoleIds = ['role-1']; + completedDeletedRoleIds = ['role-1']; + }); + And('no staff user references role "role-1"', () => { + assignedRoleIds = []; + }); + When('I retry deleted staff role events', async () => { + result = await service.retryDeletedStaffRoles(domainDataSource); + }); + Then('no staff role should be saved for event dispatch', () => { + expect(savedRoles).toEqual([]); + }); + And('zero retried roles should be returned', () => { + expect(result).toBe(0); + }); + }); + + Scenario('Retrying a completed tombstone with a dangling staff user', ({ Given, And, When, Then }) => { + Given('completed staff role tombstone "role-1" is persisted', () => { + persistedDeletedRoleIds = ['role-1']; + completedDeletedRoleIds = ['role-1']; + }); + And('a staff user references role "role-1"', () => { + assignedRoleIds = ['role-1']; + }); + When('I retry deleted staff role events', async () => { + result = await service.retryDeletedStaffRoles(domainDataSource); + }); + Then('tombstone "role-1" should re-raise its deletion event', () => { + expect(savedRoles).toHaveLength(1); + expect(savedRoles[0]?.id).toBe('role-1'); + expect(savedRoles[0]?.getIntegrationEvents()[0]).toBeInstanceOf(StaffRoleDeletedEvent); + }); + And('one retried role should be returned', () => { + expect(result).toBe(1); + }); + }); + + Scenario('Retrying one deleted staff role by id', ({ Given, When, Then, And }) => { + Given('staff role tombstone "role-2" is persisted', () => { + persistedDeletedRoleIds = ['role-2']; + }); + When('I retry deleted staff role "role-2"', async () => { + targetedResult = await service.retryDeletedStaffRole('role-2', domainDataSource); + }); + Then('only tombstone "role-2" should re-raise its deletion event', () => { + expect(savedRoles).toHaveLength(1); + expect(savedRoles[0]?.id).toBe('role-2'); + expect(savedRoles[0]?.getIntegrationEvents()).toHaveLength(1); + expect(savedRoles[0]?.getIntegrationEvents()[0]).toBeInstanceOf(StaffRoleDeletedEvent); + }); + And('the targeted role should be reported as retried', () => { + expect(targetedResult).toBe(true); + }); + }); + + Scenario('Surfacing a recovery event processing failure', ({ Given, When, Then }) => { + const recoveryError = new Error('reassignment failed'); + Given('a deleted staff role event fails during recovery', () => { + deletedRoles = [makeMockDeletedRole('role-1')]; + transactionError = recoveryError; + }); + When('I try to retry deleted staff role events', async () => { + try { + await service.retryDeletedStaffRoles(domainDataSource); + } catch (error) { + thrownError = error; + } + }); + Then('the recovery processing failure should be rethrown', () => { + expect(thrownError).toBe(recoveryError); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/services/user/staff-role-deletion-recovery.service.ts b/packages/ocom/domain/src/domain/services/user/staff-role-deletion-recovery.service.ts new file mode 100644 index 000000000..bd2be6913 --- /dev/null +++ b/packages/ocom/domain/src/domain/services/user/staff-role-deletion-recovery.service.ts @@ -0,0 +1,75 @@ +import type { DomainDataSource } from '../../../index.ts'; +import { PassportFactory } from '../../contexts/passport.ts'; + +export class StaffRoleDeletionRecoveryService { + async retryDeletedStaffRole(roleId: string, domainDataSource: DomainDataSource): Promise { + const systemPassport = this.createSystemPassport(); + let retried = false; + + await domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withTransaction(systemPassport, async (repository) => { + const role = await repository.getById(roleId); + if (!role.deletion) { + return; + } + role.retryDelete(); + await repository.save(role); + retried = true; + }); + + return retried; + } + + async retryDeletedStaffRoles(domainDataSource: DomainDataSource): Promise { + const systemPassport = this.createSystemPassport(); + let deletedRoles: StaffRoleRecoveryState[] = []; + + await domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withTransaction(systemPassport, async (repository) => { + deletedRoles = (await repository.getDeletedRoles()).map((role) => ({ + id: role.id, + reassignmentCompleted: role.deletion?.reassignmentCompletedAt !== undefined, + })); + }); + + if (deletedRoles.length === 0) { + return 0; + } + + let assignedDeletedRoleIds: string[] = []; + await domainDataSource.User.StaffUser.StaffUserUnitOfWork.withTransaction(systemPassport, async (repository) => { + assignedDeletedRoleIds = await repository.getAssignedRoleIds(deletedRoles.map(({ id }) => id)); + }); + const assignedDeletedRoleIdSet = new Set(assignedDeletedRoleIds); + const roleIdsToRetry = deletedRoles.filter(({ id, reassignmentCompleted }) => !reassignmentCompleted || assignedDeletedRoleIdSet.has(id)).map(({ id }) => id); + + if (roleIdsToRetry.length === 0) { + return 0; + } + + let retriedRoleCount = 0; + await domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withTransaction(systemPassport, async (repository) => { + for (const roleId of roleIdsToRetry) { + const deletedRole = await repository.getById(roleId); + if (!deletedRole.deletion) { + continue; + } + deletedRole.retryDelete(); + await repository.save(deletedRole); + retriedRoleCount += 1; + } + }); + + return retriedRoleCount; + } + + private createSystemPassport() { + return PassportFactory.forSystem({ + canManageStaffRolesAndPermissions: true, + isSystemAccount: true, + }); + } +} + +interface StaffRoleRecoveryState { + id: string; + reassignmentCompleted: boolean; +} diff --git a/packages/ocom/event-handler/src/handlers/index.ts b/packages/ocom/event-handler/src/handlers/index.ts index f996fb0f8..70eae3028 100644 --- a/packages/ocom/event-handler/src/handlers/index.ts +++ b/packages/ocom/event-handler/src/handlers/index.ts @@ -1,4 +1,4 @@ -import type { DomainDataSource } from '@ocom/domain'; +import { Domain, type DomainDataSource } from '@ocom/domain'; import { RegisterDomainEventHandlers } from './domain/index.ts'; import { RegisterIntegrationEventHandlers } from './integration/index.ts'; @@ -6,3 +6,7 @@ export const RegisterEventHandlers = (domainDataSource: DomainDataSource) => { RegisterDomainEventHandlers(domainDataSource); RegisterIntegrationEventHandlers(domainDataSource); }; + +export const RecoverDeletedStaffRoles = async (domainDataSource: DomainDataSource): Promise => { + return await Domain.Services.User.StaffRoleDeletionRecoveryService.retryDeletedStaffRoles(domainDataSource); +}; diff --git a/packages/ocom/event-handler/src/handlers/integration/index.ts b/packages/ocom/event-handler/src/handlers/integration/index.ts index a2065b65a..b9dc34636 100644 --- a/packages/ocom/event-handler/src/handlers/integration/index.ts +++ b/packages/ocom/event-handler/src/handlers/integration/index.ts @@ -1,6 +1,8 @@ import type { DomainDataSource } from '@ocom/domain'; import RegisterCommunityCreatedProvisionMemberAndDefaultRoleHandler from './community-created--provision-member-and-default-role.ts'; +import RegisterStaffRoleDeletedReassignStaffUsersHandler from './staff-role-deleted--reassign-staff-users.ts'; export const RegisterIntegrationEventHandlers = (domainDataSource: DomainDataSource): void => { RegisterCommunityCreatedProvisionMemberAndDefaultRoleHandler(domainDataSource); + RegisterStaffRoleDeletedReassignStaffUsersHandler(domainDataSource); }; diff --git a/packages/ocom/event-handler/src/handlers/integration/staff-role-deleted--reassign-staff-users.ts b/packages/ocom/event-handler/src/handlers/integration/staff-role-deleted--reassign-staff-users.ts new file mode 100644 index 000000000..17a94a5bc --- /dev/null +++ b/packages/ocom/event-handler/src/handlers/integration/staff-role-deleted--reassign-staff-users.ts @@ -0,0 +1,9 @@ +import { Domain, type DomainDataSource } from '@ocom/domain'; + +const { EventBusInstance, StaffRoleDeletedEvent } = Domain.Events; +export default (domainDataSource: DomainDataSource) => { + EventBusInstance.register(StaffRoleDeletedEvent, async (payload) => { + const { deletedRoleId, enterpriseAppRole, actorStaffUserId } = payload; + return await Domain.Services.User.StaffRoleDeletedReassignmentService.reassignStaffUsersToDefaultRole(deletedRoleId, enterpriseAppRole, actorStaffUserId, domainDataSource); + }); +}; diff --git a/packages/ocom/event-handler/src/index.ts b/packages/ocom/event-handler/src/index.ts index 3bf0fed4d..4d2bfdd47 100644 --- a/packages/ocom/event-handler/src/index.ts +++ b/packages/ocom/event-handler/src/index.ts @@ -1 +1 @@ -export { RegisterEventHandlers } from './handlers/index.ts'; +export { RecoverDeletedStaffRoles, RegisterEventHandlers } from './handlers/index.ts'; diff --git a/packages/ocom/graphql/src/schema/types/features/staff-role.resolvers.feature b/packages/ocom/graphql/src/schema/types/features/staff-role.resolvers.feature new file mode 100644 index 000000000..6c4b6f59b --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/features/staff-role.resolvers.feature @@ -0,0 +1,25 @@ +Feature: StaffRole resolvers + + Scenario: Deleting a staff role successfully + Given a staff user with a verified JWT + When the staffRoleDelete mutation is executed for role "607f1f77bcf86cd799439099" + Then it should call User.StaffRole.delete with the role id, initiating staff user id, and current role id + And it should return a success status + + Scenario: Reporting pending reassignment after a committed deletion + Given a staff user with a verified JWT + And the role deletion commits while reassignment remains pending + When the staffRoleDelete mutation is executed for role "607f1f77bcf86cd799439099" + Then it should return a success status with a reassignment warning + + Scenario: Unauthorized staff role deletion + Given a request without a verified JWT + When the staffRoleDelete mutation is executed without authentication + Then it should return an unauthorized failure status + And it should not call User.StaffRole.delete + + Scenario: Staff role deletion error handling + Given a staff user with a verified JWT + And the delete application service rejects with "You do not have permission to delete this role" + When the staffRoleDelete mutation is executed for role "607f1f77bcf86cd799439099" + Then it should return a failure status with the error message 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 index d9f44a154..6c5816431 100644 --- a/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature +++ b/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature @@ -106,10 +106,10 @@ Feature: Staff User Resolvers When the staffRoleUpdate mutation is executed with id "role-001" and enterpriseAppRole "Staff.TechAdmin" Then it should return success with the updated staff role - Scenario: Updating a staff role with an unauthorized enterpriseAppRole + Scenario: Updating a staff role delegates enterpriseAppRole authorization to the application service Given a user with a verifiedJwt that includes the CaseManager role When the staffRoleUpdate mutation is executed with id "role-001" and enterpriseAppRole "Staff.TechAdmin" - Then it should return failure with a permission error message + Then it should return success with the updated staff role Scenario: Updating a staff role when unauthenticated Given a user without a verifiedJwt in their context diff --git a/packages/ocom/graphql/src/schema/types/staff-role.command-mapper.ts b/packages/ocom/graphql/src/schema/types/staff-role.command-mapper.ts index fd6232778..53c48b2f0 100644 --- a/packages/ocom/graphql/src/schema/types/staff-role.command-mapper.ts +++ b/packages/ocom/graphql/src/schema/types/staff-role.command-mapper.ts @@ -7,8 +7,9 @@ import type { StaffRoleCommandUserPermissions, } from '../../../../application-services/src/contexts/user/staff-role/apply-permissions.js'; import type { StaffRoleCreateCommand } from '../../../../application-services/src/contexts/user/staff-role/create.js'; +import type { StaffRoleDeleteCommand } from '../../../../application-services/src/contexts/user/staff-role/delete.js'; import type { StaffRoleUpdateCommand } from '../../../../application-services/src/contexts/user/staff-role/update.js'; -import type { MutationStaffRoleCreateArgs, MutationStaffRoleUpdateArgs } from '../builder/generated.ts'; +import type { MutationStaffRoleCreateArgs, MutationStaffRoleDeleteArgs, MutationStaffRoleUpdateArgs } from '../builder/generated.ts'; const EnterpriseAppRoleNames = { CaseManager: 'Staff.CaseManager', @@ -70,3 +71,11 @@ export function buildStaffRoleUpdateCommand(input: NonNullable, actorStaffUserId: string, actorStaffRoleId?: string): StaffRoleDeleteCommand { + return { + roleId: String(input.id), + actorStaffUserId, + ...(actorStaffRoleId ? { actorStaffRoleId } : {}), + }; +} diff --git a/packages/ocom/graphql/src/schema/types/staff-role.graphql b/packages/ocom/graphql/src/schema/types/staff-role.graphql index 0f0b7fb3c..22d5000a6 100644 --- a/packages/ocom/graphql/src/schema/types/staff-role.graphql +++ b/packages/ocom/graphql/src/schema/types/staff-role.graphql @@ -127,6 +127,14 @@ type StaffRoleMutationResult implements MutationResult { staffRole: StaffRole } +input StaffRoleDeleteInput { + id: ObjectID! +} + +type StaffRoleDeleteMutationResult implements MutationResult { + status: MutationStatus! +} + extend type Query { staffRoles: [StaffRole!]! staffRoleById(id: ObjectID!): StaffRole @@ -135,4 +143,5 @@ extend type Query { extend type Mutation { staffRoleCreate(input: StaffRoleCreateInput!): StaffRoleMutationResult! staffRoleUpdate(input: StaffRoleUpdateInput!): StaffRoleMutationResult! + staffRoleDelete(input: StaffRoleDeleteInput!): StaffRoleDeleteMutationResult! } diff --git a/packages/ocom/graphql/src/schema/types/staff-role.resolvers.test.ts b/packages/ocom/graphql/src/schema/types/staff-role.resolvers.test.ts new file mode 100644 index 000000000..35271775c --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/staff-role.resolvers.test.ts @@ -0,0 +1,137 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { GraphQLResolveInfo } from 'graphql'; +import { expect, vi } from 'vitest'; +import type { MutationStaffRoleDeleteArgs, RequireFields } from '../builder/generated.ts'; +import type { GraphContext } from '../context.ts'; +import staffRoleResolvers from './staff-role.resolvers.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role.resolvers.feature')); + +type StaffRoleDeleteResolver = (parent: unknown, args: RequireFields, context: GraphContext, info: GraphQLResolveInfo) => Promise<{ status: { success: boolean; errorMessage?: string } }>; + +function makeMockGraphContext(verified: boolean): GraphContext { + return { + applicationServices: { + User: { + StaffRole: { + delete: vi.fn(), + }, + StaffUser: { + queryByExternalId: vi.fn().mockResolvedValue({ id: 'actor-1', roleId: 'current-role-1' }), + }, + }, + ...(verified + ? { + verifiedUser: { + verifiedJwt: { + sub: 'staff-user-sub', + roles: ['Staff.TechAdmin'], + }, + }, + } + : {}), + }, + } as unknown as GraphContext; +} + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let context: GraphContext; + let result: { status: { success: boolean; errorMessage?: string } }; + + const executeDelete = async (roleId: string) => { + const resolver = staffRoleResolvers.Mutation?.staffRoleDelete as unknown as StaffRoleDeleteResolver; + result = await resolver(null, { input: { id: roleId } }, context, {} as GraphQLResolveInfo); + }; + + BeforeEachScenario(() => { + vi.clearAllMocks(); + context = makeMockGraphContext(true); + }); + + Scenario('Deleting a staff role successfully', ({ Given, When, Then, And }) => { + Given('a staff user with a verified JWT', () => { + context = makeMockGraphContext(true); + }); + + When('the staffRoleDelete mutation is executed for role "607f1f77bcf86cd799439099"', async () => { + vi.mocked(context.applicationServices.User.StaffRole.delete).mockResolvedValue({ reassignmentPending: false }); + await executeDelete('607f1f77bcf86cd799439099'); + }); + + Then('it should call User.StaffRole.delete with the role id, initiating staff user id, and current role id', () => { + expect(context.applicationServices.User.StaffUser.queryByExternalId).toHaveBeenCalledWith({ externalId: 'staff-user-sub' }); + expect(context.applicationServices.User.StaffRole.delete).toHaveBeenCalledWith({ + roleId: '607f1f77bcf86cd799439099', + actorStaffUserId: 'actor-1', + actorStaffRoleId: 'current-role-1', + }); + }); + + And('it should return a success status', () => { + expect(result.status.success).toBe(true); + }); + }); + + Scenario('Reporting pending reassignment after a committed deletion', ({ Given, And, When, Then }) => { + Given('a staff user with a verified JWT', () => { + context = makeMockGraphContext(true); + }); + + And('the role deletion commits while reassignment remains pending', () => { + vi.mocked(context.applicationServices.User.StaffRole.delete).mockResolvedValue({ reassignmentPending: true }); + }); + + When('the staffRoleDelete mutation is executed for role "607f1f77bcf86cd799439099"', async () => { + await executeDelete('607f1f77bcf86cd799439099'); + }); + + Then('it should return a success status with a reassignment warning', () => { + expect(result.status).toEqual({ + success: true, + errorMessage: 'Role deleted, but assigned staff users could not be reassigned; recovery will retry automatically', + }); + }); + }); + + Scenario('Unauthorized staff role deletion', ({ Given, When, Then, And }) => { + Given('a request without a verified JWT', () => { + context = makeMockGraphContext(false); + }); + + When('the staffRoleDelete mutation is executed without authentication', async () => { + await executeDelete('607f1f77bcf86cd799439099'); + }); + + Then('it should return an unauthorized failure status', () => { + expect(result.status.success).toBe(false); + expect(result.status.errorMessage).toBe('Unauthorized'); + }); + + And('it should not call User.StaffRole.delete', () => { + expect(context.applicationServices.User.StaffRole.delete).not.toHaveBeenCalled(); + }); + }); + + Scenario('Staff role deletion error handling', ({ Given, And, When, Then }) => { + Given('a staff user with a verified JWT', () => { + context = makeMockGraphContext(true); + }); + + And('the delete application service rejects with "You do not have permission to delete this role"', () => { + vi.mocked(context.applicationServices.User.StaffRole.delete).mockRejectedValue(new Error('You do not have permission to delete this role')); + }); + + When('the staffRoleDelete mutation is executed for role "607f1f77bcf86cd799439099"', async () => { + await executeDelete('607f1f77bcf86cd799439099'); + }); + + Then('it should return a failure status with the error message', () => { + expect(result.status.success).toBe(false); + expect(result.status.errorMessage).toBe('You do not have permission to delete this role'); + }); + }); +}); diff --git a/packages/ocom/graphql/src/schema/types/staff-role.resolvers.ts b/packages/ocom/graphql/src/schema/types/staff-role.resolvers.ts index 1b8bd6606..2c79a74c6 100644 --- a/packages/ocom/graphql/src/schema/types/staff-role.resolvers.ts +++ b/packages/ocom/graphql/src/schema/types/staff-role.resolvers.ts @@ -1,7 +1,9 @@ import type { GraphQLResolveInfo } from 'graphql'; -import type { MutationStaffRoleCreateArgs, MutationStaffRoleUpdateArgs, RequireFields, Resolvers } from '../builder/generated.ts'; +import type { MutationStaffRoleCreateArgs, MutationStaffRoleDeleteArgs, MutationStaffRoleUpdateArgs, RequireFields, Resolvers } from '../builder/generated.ts'; import type { GraphContext } from '../context.ts'; -import { buildStaffRoleCreateCommand, buildStaffRoleUpdateCommand } from './staff-role.command-mapper.ts'; +import { buildStaffRoleCreateCommand, buildStaffRoleDeleteCommand, buildStaffRoleUpdateCommand } from './staff-role.command-mapper.ts'; + +const REASSIGNMENT_PENDING_MESSAGE = 'Role deleted, but assigned staff users could not be reassigned; recovery will retry automatically'; const staffRole: Resolvers = { Query: { @@ -56,6 +58,30 @@ const staffRole: Resolvers = { return { status: { success: false, errorMessage: message } }; } }, + + staffRoleDelete: async (_parent, args: RequireFields, context: GraphContext, _info: GraphQLResolveInfo) => { + const jwt = context.applicationServices.verifiedUser?.verifiedJwt; + if (!jwt) { + return { status: { success: false, errorMessage: 'Unauthorized' } }; + } + try { + const actorStaffUser = await context.applicationServices.User.StaffUser.queryByExternalId({ externalId: jwt.sub }); + if (!actorStaffUser) { + throw new Error('Current staff user not found'); + } + const actorStaffRoleId = actorStaffUser.roleId ?? actorStaffUser.role?.id; + const command = buildStaffRoleDeleteCommand(args.input, String(actorStaffUser.id), actorStaffRoleId ? String(actorStaffRoleId) : undefined); + const deletionResult = await context.applicationServices.User.StaffRole.delete(command); + if (deletionResult.reassignmentPending) { + return { status: { success: true, errorMessage: REASSIGNMENT_PENDING_MESSAGE } }; + } + return { status: { success: true } }; + } catch (error) { + console.error('StaffRole > staffRoleDelete: ', error); + const { message } = error as Error; + return { status: { success: false, errorMessage: message } }; + } + }, }, }; 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 index a87e0e6b6..3f0331906 100644 --- a/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts +++ b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts @@ -451,10 +451,10 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { result = await callMutation('staffRoleCreate', context, { input: { roleName: 'New Role', enterpriseAppRole: 'Staff.TechAdmin' } }); }); - Then('it should return success with the updated staff role', () => { - const res = result as { status: { success: boolean }; staffRole: StaffRoleEntity }; - expect(res.status.success).toBe(true); - expect(res.staffRole).toBeDefined(); + Then('it should return failure with a permission error message', () => { + const { status } = result as { status: { success: boolean; errorMessage: string } }; + expect(status.success).toBe(false); + expect(status.errorMessage).toContain('do not have permission'); }); }); @@ -511,19 +511,21 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { }); }); - Scenario('Updating a staff role with an unauthorized enterpriseAppRole', ({ Given, When, Then }) => { + Scenario('Updating a staff role delegates enterpriseAppRole authorization to the application service', ({ Given, When, Then }) => { Given('a user with a verifiedJwt that includes the CaseManager role', () => { context = makeMockGraphContext({ jwt: { roles: ['Staff.CaseManager'] } }); }); When('the staffRoleUpdate mutation is executed with id "role-001" and enterpriseAppRole "Staff.TechAdmin"', async () => { + const mockRole = createMockStaffRole({ id: 'role-001', enterpriseAppRole: 'Staff.TechAdmin' }); + vi.mocked(context.applicationServices.User.StaffRole.update).mockResolvedValue(mockRole); result = await callMutation('staffRoleUpdate', context, { input: { id: 'role-001', roleName: 'Updated', enterpriseAppRole: 'Staff.TechAdmin' } }); }); - Then('it should return success with the updated staff user', () => { - const res = result as { status: { success: boolean }; staffUser: StaffUserEntity }; + Then('it should return success with the updated staff role', () => { + const res = result as { status: { success: boolean }; staffRole: StaffRoleEntity }; expect(res.status.success).toBe(true); - expect(res.staffUser).toBeDefined(); + expect(res.staffRole).toBeDefined(); }); }); @@ -597,6 +599,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { }); When('the staffUserAssignRole mutation is executed with staffUserId "user-001" and roleId "role-001"', async () => { + vi.mocked(context.applicationServices.User.StaffUser.assignRole).mockRejectedValue(new Error('You do not have permission to assign a role with enterprise app role type: Staff.TechAdmin')); result = await callMutation('staffUserAssignRole', context, { input: { staffUserId: 'user-001', roleId: 'role-001' } }); }); 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 3fba1f420..2a56140de 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 @@ -48,6 +48,11 @@ Feature: StaffRoleDomainAdapter When I get the roleType property Then it should return "staff" + Scenario: Getting a completed deletion tombstone + Given a StaffRoleDomainAdapter for a completed deletion tombstone + When I get the deletion property + Then it should return the reassignment completion timestamp + Scenario: Getting the permissions property Given a StaffRoleDomainAdapter for the document When I get the permissions property 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 1874bac51..f61bc2239 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 @@ -7,6 +7,7 @@ Feature: StaffRoleRepository Scenario: Getting a staff role by id When I call getById with "role-1" Then I should receive a StaffRole domain object + And the lookup should use the repository transaction session And the domain object's roleName should be "Manager" And the domain object's isDefault should be false And the domain object's roleType should be "staff" @@ -41,4 +42,16 @@ Feature: StaffRoleRepository Then I should receive a new StaffRole domain object And the domain object's roleName should be "Supervisor" And the domain object's isDefault should be false - And the domain object's roleType should be "staff" \ No newline at end of file + And the domain object's roleType should be "staff" + + Scenario: Getting logically deleted staff roles for recovery + Given a StaffRole document has a durable deletion tombstone + When I get deleted staff roles + Then the deleted staff role should be returned + And the recovery lookup should use the repository transaction session + + Scenario: Marking deleted role reassignment complete + Given a StaffRole document has a durable deletion tombstone + When I mark role "role-1" reassignment complete + Then the tombstone completion timestamp should be updated atomically + And the completion update should use the repository transaction session \ No newline at end of file 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 bcf9cf40e..43a6239b2 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 @@ -206,6 +206,27 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => }); }); + Scenario('Getting a completed deletion tombstone', ({ Given, When, Then }) => { + const reassignmentCompletedAt = new Date('2026-07-23T12:05:00.000Z'); + Given('a StaffRoleDomainAdapter for a completed deletion tombstone', () => { + doc = makeStaffRoleDoc({ + deletion: { + actorStaffUserId: 'actor-1', + enterpriseAppRole: 'Staff.CaseManager', + deletedAt: new Date('2026-07-23T12:00:00.000Z'), + reassignmentCompletedAt, + }, + }); + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the deletion property', () => { + result = adapter.deletion; + }); + Then('it should return the reassignment completion timestamp', () => { + expect(result).toEqual(expect.objectContaining({ reassignmentCompletedAt })); + }); + }); + Scenario('Getting the permissions property', ({ Given, When, Then }) => { Given('a StaffRoleDomainAdapter for the document', () => { adapter = new StaffRoleDomainAdapter(doc); 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 1a94a9907..08f05f8c2 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 @@ -46,6 +46,22 @@ export class StaffRoleDomainAdapter extends MongooseSeedwork.MongooseDomainAdapt this.doc.isDefault = isDefault; } + get deletion(): Domain.Contexts.User.StaffRole.StaffRoleDeletion | undefined { + if (!this.doc.deletion) { + return undefined; + } + return { + actorStaffUserId: this.doc.deletion.actorStaffUserId, + enterpriseAppRole: this.doc.deletion.enterpriseAppRole, + deletedAt: this.doc.deletion.deletedAt, + reassignmentCompletedAt: this.doc.deletion.reassignmentCompletedAt, + }; + } + + set deletion(deletion: Domain.Contexts.User.StaffRole.StaffRoleDeletion | undefined) { + this.doc.set('deletion', deletion); + } + get permissions(): Domain.Contexts.User.StaffRole.StaffRolePermissionsProps { if (!this.doc.permissions) { this.doc.set('permissions', {}); 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 0c6515a5d..5bd10ac57 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 @@ -14,12 +14,17 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role.repository.feature')); function makeStaffRoleDoc(overrides: Partial = {}) { + const createdAt = new Date('2024-01-01T00:00:00.000Z'); + const updatedAt = new Date('2024-02-01T00:00:00.000Z'); const base = { _id: 'role-1', roleName: 'Manager', enterpriseAppRole: 'Staff.CaseManager', isDefault: false, roleType: 'staff', + schemaVersion: '1.0.0', + createdAt, + updatedAt, permissions: { communityPermissions: { canManageStaffRolesAndPermissions: false, @@ -51,6 +56,20 @@ function makeStaffRoleDoc(overrides: Partial = {}) { set(key: keyof StaffRole, value: unknown) { (this as StaffRole)[key] = value as never; }, + toObject() { + return { + _id: this._id, + roleName: this.roleName, + enterpriseAppRole: this.enterpriseAppRole, + isDefault: this.isDefault, + roleType: this.roleType, + schemaVersion: this.schemaVersion, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + permissions: this.permissions, + deletion: this.deletion, + }; + }, ...overrides, } as StaffRole; return vi.mocked(base); @@ -73,6 +92,9 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { let staffRoleDoc: StaffRole; let eventBus: EventBus; let session: ClientSession; + let capturedFindByIdSession: ClientSession | undefined; + let capturedFindSession: ClientSession | undefined; + let findOneAndUpdateMock: ReturnType; BeforeEachScenario(() => { staffRoleDoc = makeStaffRoleDoc(); @@ -83,24 +105,45 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { const ModelMock = function (this: StaffRole) { Object.assign(this, makeStaffRoleDoc()); }; + capturedFindByIdSession = undefined; + capturedFindSession = undefined; + findOneAndUpdateMock = vi.fn(() => ({ + exec: vi.fn(async () => staffRoleDoc), + })); Object.assign(ModelMock, { findById: vi.fn((id: string) => ({ - exec: vi.fn(() => (id === staffRoleDoc._id ? staffRoleDoc : null)), + session: vi.fn((receivedSession: ClientSession) => { + capturedFindByIdSession = receivedSession; + return { + exec: vi.fn(() => (id === staffRoleDoc._id ? staffRoleDoc : null)), + }; + }), + })), + findOne: vi.fn((query: { roleName?: string; enterpriseAppRole?: string; isDefault?: boolean; 'deletion.deletedAt'?: { $exists: boolean } }) => ({ + session: vi.fn((_receivedSession: ClientSession) => ({ + exec: vi.fn(() => { + if (query.roleName !== undefined) { + return query.roleName === staffRoleDoc.roleName ? staffRoleDoc : null; + } + if (query.enterpriseAppRole !== undefined) { + return query.enterpriseAppRole === staffRoleDoc.enterpriseAppRole && query.isDefault === staffRoleDoc.isDefault ? staffRoleDoc : null; + } + if (query.enterpriseAppRole && query.isDefault === true) { + return query.enterpriseAppRole === staffRoleDoc.enterpriseAppRole && staffRoleDoc.isDefault ? staffRoleDoc : null; + } + return null; + }), + })), })), - findOne: vi.fn((query: { roleName?: string; enterpriseAppRole?: string; isDefault?: boolean }) => ({ - exec: vi.fn(() => { - if (query.roleName !== undefined) { - return query.roleName === staffRoleDoc.roleName ? staffRoleDoc : null; - } - if (query.enterpriseAppRole !== undefined) { - return query.enterpriseAppRole === staffRoleDoc.enterpriseAppRole && query.isDefault === staffRoleDoc.isDefault ? staffRoleDoc : null; - } - if (query.enterpriseAppRole && query.isDefault === true) { - return query.enterpriseAppRole === staffRoleDoc.enterpriseAppRole && staffRoleDoc.isDefault ? staffRoleDoc : null; - } - return null; + find: vi.fn((query: { 'deletion.deletedAt'?: { $exists: boolean } }) => ({ + session: vi.fn((receivedSession: ClientSession) => { + capturedFindSession = receivedSession; + return { + exec: vi.fn(() => (query['deletion.deletedAt']?.$exists === Boolean(staffRoleDoc.deletion) ? [staffRoleDoc] : [])), + }; }), })), + findOneAndUpdate: findOneAndUpdateMock, prototype: {}, }); @@ -127,6 +170,9 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { Then('I should receive a StaffRole domain object', () => { expect(result).toBeInstanceOf(Domain.Contexts.User.StaffRole.StaffRole); }); + And('the lookup should use the repository transaction session', () => { + expect(capturedFindByIdSession).toBe(session); + }); And('the domain object\'s roleName should be "Manager"', () => { expect(result.roleName).toBe('Manager'); }); @@ -227,4 +273,63 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { expect(result.roleType).toBe('staff'); }); }); + + Scenario('Getting logically deleted staff roles for recovery', ({ Given, When, Then, And }) => { + let result: Domain.Contexts.User.StaffRole.StaffRole[]; + Given('a StaffRole document has a durable deletion tombstone', () => { + staffRoleDoc = makeStaffRoleDoc({ + deletion: { + actorStaffUserId: 'actor-1', + enterpriseAppRole: 'Staff.CaseManager', + deletedAt: new Date('2026-07-23T12:00:00.000Z'), + }, + }); + }); + When('I get deleted staff roles', async () => { + result = await repo.getDeletedRoles(); + }); + Then('the deleted staff role should be returned', () => { + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe('role-1'); + expect(result[0]?.deletion?.actorStaffUserId).toBe('actor-1'); + }); + And('the recovery lookup should use the repository transaction session', () => { + expect(capturedFindSession).toBe(session); + }); + }); + + Scenario('Marking deleted role reassignment complete', ({ Given, When, Then, And }) => { + const completedAt = new Date('2026-07-23T12:05:00.000Z'); + Given('a StaffRole document has a durable deletion tombstone', () => { + staffRoleDoc = makeStaffRoleDoc({ + deletion: { + actorStaffUserId: 'actor-1', + enterpriseAppRole: 'Staff.CaseManager', + deletedAt: new Date('2026-07-23T12:00:00.000Z'), + }, + }); + }); + When('I mark role "role-1" reassignment complete', async () => { + await repo.markReassignmentCompleted('role-1', completedAt); + }); + Then('the tombstone completion timestamp should be updated atomically', () => { + expect(findOneAndUpdateMock).toHaveBeenCalledWith( + { + _id: 'role-1', + 'deletion.deletedAt': { $exists: true }, + }, + { + $set: { + 'deletion.reassignmentCompletedAt': completedAt, + }, + }, + expect.objectContaining({ + runValidators: true, + }), + ); + }); + And('the completion update should use the repository transaction session', () => { + expect(findOneAndUpdateMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ session })); + }); + }); }); 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 e0e2acb49..364d1829e 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 @@ -12,7 +12,7 @@ export class StaffRoleRepository implements Domain.Contexts.User.StaffRole.StaffRoleRepository { async getById(id: string): Promise> { - const staffRole = await this.model.findById(id).exec(); + const staffRole = await this.model.findById(id).session(this.session).exec(); if (!staffRole) { throw new NotFoundError(`StaffRole with id ${id} not found`); } @@ -20,7 +20,10 @@ export class StaffRoleRepository } async getByRoleName(roleName: string): Promise> { - const staffRole = await this.model.findOne({ roleName }).exec(); + const staffRole = await this.model + .findOne({ roleName, 'deletion.deletedAt': { $exists: false } }) + .session(this.session) + .exec(); if (!staffRole) { throw new NotFoundError(`StaffRole with roleName ${roleName} not found`); } @@ -28,13 +31,47 @@ export class StaffRoleRepository } async getDefaultRoleByEnterpriseAppRole(enterpriseAppRole: string): Promise> { - const staffRole = await this.model.findOne({ isDefault: true, enterpriseAppRole }).exec(); + const staffRole = await this.model + .findOne({ isDefault: true, enterpriseAppRole, 'deletion.deletedAt': { $exists: false } }) + .session(this.session) + .exec(); if (!staffRole) { throw new NotFoundError(`Default StaffRole with enterpriseAppRole ${enterpriseAppRole} not found`); } return this.typeConverter.toDomain(staffRole, this.passport); } + async getDeletedRoles(): Promise[]> { + const staffRoles = await this.model + .find({ 'deletion.deletedAt': { $exists: true } }) + .session(this.session) + .exec(); + return staffRoles.map((staffRole) => this.typeConverter.toDomain(staffRole, this.passport)); + } + + async markReassignmentCompleted(roleId: string, completedAt: Date): Promise { + const updatedRole = await this.model + .findOneAndUpdate( + { + _id: roleId, + 'deletion.deletedAt': { $exists: true }, + }, + { + $set: { + 'deletion.reassignmentCompletedAt': completedAt, + }, + }, + { + session: this.session, + runValidators: true, + }, + ) + .exec(); + if (!updatedRole) { + throw new NotFoundError(`Deleted StaffRole with id ${roleId} not found`); + } + } + 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)); diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.uow.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.uow.ts index 7ce6efe03..07e410e22 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.uow.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.uow.ts @@ -1,12 +1,13 @@ -import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; import { InProcEventBusInstance, NodeEventBusInstance } from '@cellix/event-bus-seedwork-node'; - +import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; +import type { StaffRoleModelType } from '@ocom/data-sources-mongoose-models/role/staff-role'; import type { Domain } from '@ocom/domain'; import { StaffRoleConverter } from './staff-role.domain-adapter.ts'; import { StaffRoleRepository } from './staff-role.repository.ts'; -import type { StaffRoleModelType } from '@ocom/data-sources-mongoose-models/role/staff-role'; export const getStaffRoleUnitOfWork = (staffRoleModel: StaffRoleModelType, passport: Domain.Passport): Domain.Contexts.User.StaffRole.StaffRoleUnitOfWork => { - const unitOfWork = new MongooseSeedwork.MongoUnitOfWork(InProcEventBusInstance, NodeEventBusInstance, staffRoleModel, new StaffRoleConverter(), StaffRoleRepository); + const unitOfWork = new MongooseSeedwork.MongoUnitOfWork(InProcEventBusInstance, NodeEventBusInstance, staffRoleModel, new StaffRoleConverter(), StaffRoleRepository, { + integrationEventErrors: 'propagate', + }); return MongooseSeedwork.getInitializedUnitOfWork(unitOfWork, passport); }; diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-user/features/staff-user.domain-adapter.feature b/packages/ocom/persistence/src/datasources/domain/user/staff-user/features/staff-user.domain-adapter.feature index db0b20809..51c2b96f7 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-user/features/staff-user.domain-adapter.feature +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-user/features/staff-user.domain-adapter.feature @@ -80,6 +80,11 @@ Feature: StaffUserDomainAdapter When I get the role property Then it should return a StaffRoleProps object + Scenario: Getting a logically deleted role when populated + Given a StaffUserDomainAdapter for the document with a populated deleted role + When I get the role property + Then the deleted role should be hidden while its role id remains available + Scenario: Getting role when role is ObjectId Given a StaffUserDomainAdapter for the document with role as ObjectId When I get the role property diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-user/features/staff-user.repository.feature b/packages/ocom/persistence/src/datasources/domain/user/staff-user/features/staff-user.repository.feature index b7cf1157c..63e75d089 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-user/features/staff-user.repository.feature +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-user/features/staff-user.repository.feature @@ -25,6 +25,39 @@ Feature: StaffUserRepository When I call getByExternalId with "87654321-4321-4321-4321-210987654321" Then it should throw an error indicating "StaffUser with externalId 87654321-4321-4321-4321-210987654321 not found" + Scenario: Getting all staff users assigned to a role + Given two staff users are assigned to the role with ID "607f1f77bcf86cd799439099" + When I call getAllAssignedToRole with "607f1f77bcf86cd799439099" + Then it should return the staff user aggregates assigned to that role + + Scenario: Getting all staff users assigned to a role with no assignees + Given no staff users are assigned to the role with ID "607f1f77bcf86cd799439100" + When I call getAllAssignedToRole with "607f1f77bcf86cd799439100" + Then it should return an empty list + + Scenario: Getting assigned role ids for deleted-role reconciliation + Given staff users reference roles "607f1f77bcf86cd799439099" and "607f1f77bcf86cd799439100" + When I get assigned role ids from those candidates + Then it should return each referenced role id once + And the role-id lookup should use the repository transaction session + + Scenario: Getting a bounded batch of staff-user ids assigned to a role + Given three staff users are assigned to the role with ID "607f1f77bcf86cd799439099" + When I get a batch of two assigned staff-user ids + Then it should return only two staff-user ids + And the assigned-user batch lookup should use the limit and transaction session + + Scenario: Conditionally updating a staff user's role + Given a staff user is still assigned to role "607f1f77bcf86cd799439099" + When I conditionally replace that role with "607f1f77bcf86cd799439100" + Then the role and audit entry should be updated in the User transaction + And the conditional update should report success + + Scenario: Conditional role update loses to a newer assignment + Given a staff user's role no longer matches "607f1f77bcf86cd799439099" + When I conditionally replace that role with "607f1f77bcf86cd799439100" + Then the conditional update should report no change + Scenario: Creating a new staff user instance Given valid parameters for a new staff user When I call getNewInstance with externalId "12345678-1234-1234-8123-123456789012", firstName "John", lastName "Doe", email "john.doe@example.com" diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.domain-adapter.test.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.domain-adapter.test.ts index a71c342eb..915779cea 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.domain-adapter.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.domain-adapter.test.ts @@ -2,13 +2,12 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; - +import type { StaffRole } from '@ocom/data-sources-mongoose-models/role/staff-role'; +import type { StaffUser } from '@ocom/data-sources-mongoose-models/user/staff-user'; import type { Domain } from '@ocom/domain'; import { expect, vi } from 'vitest'; import { StaffRoleDomainAdapter } from '../staff-role/staff-role.domain-adapter.ts'; import { StaffUserDomainAdapter } from './staff-user.domain-adapter.ts'; -import type { StaffRole } from '@ocom/data-sources-mongoose-models/role/staff-role'; -import type { StaffUser } from '@ocom/data-sources-mongoose-models/user/staff-user'; const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -301,6 +300,27 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); }); + Scenario('Getting a logically deleted role when populated', ({ Given, When, Then }) => { + Given('a StaffUserDomainAdapter for the document with a populated deleted role', () => { + const roleDoc = makeStaffRoleDoc({ + deletion: { + actorStaffUserId: 'actor-1', + enterpriseAppRole: 'Staff.TechAdmin', + deletedAt: new Date('2026-07-23T12:00:00.000Z'), + }, + }); + doc.role = roleDoc; + adapter = new StaffUserDomainAdapter(doc); + }); + When('I get the role property', () => { + // Test will check the value + }); + Then('the deleted role should be hidden while its role id remains available', () => { + expect(adapter.role).toBeUndefined(); + expect(adapter.roleId).toBe('507f1f77bcf86cd799439012'); + }); + }); + Scenario('Getting role when role is ObjectId', ({ Given, When, Then }) => { Given('a StaffUserDomainAdapter for the document with role as ObjectId', () => { doc.role = new MongooseSeedwork.ObjectId('507f1f77bcf86cd799439012'); diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.domain-adapter.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.domain-adapter.ts index 206e7101f..c3aa22946 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.domain-adapter.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.domain-adapter.ts @@ -13,7 +13,21 @@ export class StaffUserDomainAdapter extends MongooseSeedwork.MongooseDomainAdapt if (this.doc.role instanceof MongooseSeedwork.ObjectId) { return undefined as unknown as Domain.Contexts.User.StaffRole.StaffRoleProps; } - return new StaffRoleDomainAdapter(this.doc.role as StaffRole); + const role = this.doc.role as StaffRole; + if (role.deletion) { + return undefined as unknown as Domain.Contexts.User.StaffRole.StaffRoleProps; + } + return new StaffRoleDomainAdapter(role); + } + + get roleId(): string | undefined { + if (!this.doc.role) { + return undefined; + } + if (this.doc.role instanceof MongooseSeedwork.ObjectId) { + return this.doc.role.toString(); + } + return String((this.doc.role as StaffRole).id); } setRoleRef(role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | Domain.Contexts.User.StaffRole.StaffRole | undefined): void { diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.repository.test.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.repository.test.ts index f59ffa3a0..7d5d18857 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.repository.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.repository.test.ts @@ -1,14 +1,14 @@ -import type { EventBus } from '@cellix/domain-seedwork/event-bus'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; -import { expect, vi } from 'vitest'; +import type { EventBus } from '@cellix/domain-seedwork/event-bus'; +import { NotFoundError } from '@cellix/domain-seedwork/repository'; +import type { StaffUser, StaffUserModelType } from '@ocom/data-sources-mongoose-models/user/staff-user'; import { Domain } from '@ocom/domain'; - -import { StaffUserRepository } from './staff-user.repository.ts'; +import { type ClientSession, Types } from 'mongoose'; +import { expect, vi } from 'vitest'; import { StaffUserConverter, type StaffUserDomainAdapter } from './staff-user.domain-adapter.ts'; -import type { ClientSession } from 'mongoose'; -import type { StaffUser, StaffUserModelType } from '@ocom/data-sources-mongoose-models/user/staff-user'; +import { StaffUserRepository } from './staff-user.repository.ts'; const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -53,6 +53,9 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { let staffUserDoc: StaffUser; let result: Domain.Contexts.User.StaffUser.StaffUser; let findByIdAndDeleteMock: ReturnType; + let findOneAndUpdateMock: ReturnType; + let findMock: ReturnType; + let session: ClientSession; BeforeEachScenario(() => { staffUserDoc = makeStaffUserDoc(); @@ -70,11 +73,32 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { exec: vi.fn(async () => (id === '507f1f77bcf86cd799439011' ? {} : null)), })); + findOneAndUpdateMock = vi.fn(() => ({ + exec: vi.fn().mockResolvedValue(staffUserDoc), + })); + findMock = vi.fn((query: Record) => ({ + session: vi.fn(() => ({ + exec: vi.fn(() => { + const role = (query as { role?: unknown }).role; + if (typeof role === 'string') { + return role === '607f1f77bcf86cd799439099' ? [staffUserDoc, makeStaffUserDoc({ firstName: 'Jane' })] : []; + } + return [ + makeStaffUserDoc({ role: new Types.ObjectId('607f1f77bcf86cd799439099') }), + makeStaffUserDoc({ role: new Types.ObjectId('607f1f77bcf86cd799439099') }), + makeStaffUserDoc({ role: new Types.ObjectId('607f1f77bcf86cd799439100') }), + ]; + }), + })), + })); Object.assign(ModelMock, { findById: vi.fn((id: string) => ({ populate: vi.fn(() => ({ exec: vi.fn(async () => (id === '507f1f77bcf86cd799439011' ? staffUserDoc : null)), })), + session: vi.fn(() => ({ + exec: vi.fn(async () => (id === '507f1f77bcf86cd799439011' ? staffUserDoc : null)), + })), })), findOne: vi.fn((query: Record) => ({ populate: vi.fn(() => ({ @@ -82,12 +106,14 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { exec: vi.fn(async () => (query['externalId'] === '12345678-1234-1234-8123-123456789012' ? staffUserDoc : null)), })), })), + find: findMock, + findOneAndUpdate: findOneAndUpdateMock, findByIdAndDelete: findByIdAndDeleteMock, }); // Provide minimal eventBus and session mocks const eventBus = { publish: vi.fn() } as unknown as EventBus; - const session = { startTransaction: vi.fn(), endSession: vi.fn() } as unknown as ClientSession; + session = { startTransaction: vi.fn(), endSession: vi.fn() } as unknown as ClientSession; // Create repository with correct constructor parameters repo = new StaffUserRepository(passport, ModelMock as unknown as StaffUserModelType, converter, eventBus, session); @@ -124,6 +150,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { // Test will check the error }); Then('it should throw an error indicating "StaffUser with id 507f1f77bcf86cd799439012 not found"', async () => { + await expect(repo.getById('507f1f77bcf86cd799439012')).rejects.toBeInstanceOf(NotFoundError); await expect(repo.getById('507f1f77bcf86cd799439012')).rejects.toThrow('StaffUser with id 507f1f77bcf86cd799439012 not found'); }); }); @@ -153,10 +180,149 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { // Test will check the error }); Then('it should throw an error indicating "StaffUser with externalId 87654321-4321-4321-4321-210987654321 not found"', async () => { + await expect(repo.getByExternalId('87654321-4321-4321-4321-210987654321')).rejects.toBeInstanceOf(NotFoundError); await expect(repo.getByExternalId('87654321-4321-4321-4321-210987654321')).rejects.toThrow('StaffUser with externalId 87654321-4321-4321-4321-210987654321 not found'); }); }); + Scenario('Getting all staff users assigned to a role', ({ Given, When, Then }) => { + let assigned: Domain.Contexts.User.StaffUser.StaffUser[]; + Given('two staff users are assigned to the role with ID "607f1f77bcf86cd799439099"', () => { + // Already mocked in BeforeEachScenario + }); + When('I call getAllAssignedToRole with "607f1f77bcf86cd799439099"', async () => { + assigned = await repo.getAllAssignedToRole('607f1f77bcf86cd799439099'); + }); + Then('it should return the staff user aggregates assigned to that role', () => { + expect(assigned).toHaveLength(2); + for (const staffUser of assigned) { + expect(staffUser).toBeInstanceOf(Domain.Contexts.User.StaffUser.StaffUser); + } + expect(assigned[0]?.firstName).toBe('John'); + expect(assigned[1]?.firstName).toBe('Jane'); + }); + }); + + Scenario('Getting all staff users assigned to a role with no assignees', ({ Given, When, Then }) => { + let assigned: Domain.Contexts.User.StaffUser.StaffUser[]; + Given('no staff users are assigned to the role with ID "607f1f77bcf86cd799439100"', () => { + // Already mocked to return an empty list + }); + When('I call getAllAssignedToRole with "607f1f77bcf86cd799439100"', async () => { + assigned = await repo.getAllAssignedToRole('607f1f77bcf86cd799439100'); + }); + Then('it should return an empty list', () => { + expect(assigned).toEqual([]); + }); + }); + + Scenario('Getting a bounded batch of staff-user ids assigned to a role', ({ Given, When, Then, And }) => { + let assignedUserIds: string[]; + const limitMock = vi.fn(); + const sessionMock = vi.fn(); + Given('three staff users are assigned to the role with ID "607f1f77bcf86cd799439099"', () => { + findMock.mockReturnValueOnce({ + limit: limitMock.mockImplementation(() => ({ + session: sessionMock.mockImplementation(() => ({ + exec: vi.fn(() => [makeStaffUserDoc({ id: new Types.ObjectId('507f1f77bcf86cd799439011') }), makeStaffUserDoc({ id: new Types.ObjectId('507f1f77bcf86cd799439012') })]), + })), + })), + }); + }); + When('I get a batch of two assigned staff-user ids', async () => { + assignedUserIds = await repo.getAssignedUserIdsToRoleBatch('607f1f77bcf86cd799439099', 2); + }); + Then('it should return only two staff-user ids', () => { + expect(assignedUserIds).toEqual(['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012']); + expect(findMock).toHaveBeenCalledWith({ role: '607f1f77bcf86cd799439099' }, { _id: 1 }); + }); + And('the assigned-user batch lookup should use the limit and transaction session', () => { + expect(limitMock).toHaveBeenCalledWith(2); + expect(sessionMock).toHaveBeenCalledWith(session); + }); + }); + + Scenario('Getting assigned role ids for deleted-role reconciliation', ({ Given, When, Then, And }) => { + let assignedRoleIds: string[]; + Given('staff users reference roles "607f1f77bcf86cd799439099" and "607f1f77bcf86cd799439100"', () => { + // The role-projection query is mocked in BeforeEachScenario. + }); + When('I get assigned role ids from those candidates', async () => { + assignedRoleIds = await repo.getAssignedRoleIds(['607f1f77bcf86cd799439099', '607f1f77bcf86cd799439100']); + }); + Then('it should return each referenced role id once', () => { + expect(assignedRoleIds).toEqual(['607f1f77bcf86cd799439099', '607f1f77bcf86cd799439100']); + }); + And('the role-id lookup should use the repository transaction session', () => { + expect(findMock.mock.calls[0]?.[1]).toEqual({ role: 1 }); + const queryResult = findMock.mock.results[0]?.value as { session: ReturnType }; + expect(queryResult.session).toHaveBeenCalledWith(session); + }); + }); + + Scenario("Conditionally updating a staff user's role", ({ Given, When, Then, And }) => { + let updateApplied = false; + Given('a staff user is still assigned to role "607f1f77bcf86cd799439099"', () => { + findOneAndUpdateMock.mockReturnValue({ + exec: vi.fn().mockResolvedValue(staffUserDoc), + }); + }); + When('I conditionally replace that role with "607f1f77bcf86cd799439100"', async () => { + updateApplied = await repo.setRoleIfCurrent({ + staffUserId: '507f1f77bcf86cd799439011', + expectedCurrentRoleId: '607f1f77bcf86cd799439099', + replacementRoleId: '607f1f77bcf86cd799439100', + activityType: 'ROLE_ASSIGNED', + activityDescription: 'Reassigned safely', + activityByStaffUserId: '507f1f77bcf86cd799439013', + }); + }); + Then('the role and audit entry should be updated in the User transaction', () => { + expect(findOneAndUpdateMock).toHaveBeenCalledWith( + expect.objectContaining({ + _id: expect.anything(), + role: expect.anything(), + }), + expect.objectContaining({ + $set: { role: expect.anything() }, + $push: { + activityLog: expect.objectContaining({ + activityType: 'ROLE_ASSIGNED', + activityDescription: 'Reassigned safely', + activityBy: expect.anything(), + }), + }, + }), + expect.objectContaining({ session, runValidators: true }), + ); + }); + And('the conditional update should report success', () => { + expect(updateApplied).toBe(true); + }); + }); + + Scenario('Conditional role update loses to a newer assignment', ({ Given, When, Then }) => { + let updateApplied = true; + Given('a staff user\'s role no longer matches "607f1f77bcf86cd799439099"', () => { + findOneAndUpdateMock.mockReturnValue({ + exec: vi.fn().mockResolvedValue(null), + }); + }); + When('I conditionally replace that role with "607f1f77bcf86cd799439100"', async () => { + updateApplied = await repo.setRoleIfCurrent({ + staffUserId: '507f1f77bcf86cd799439011', + expectedCurrentRoleId: '607f1f77bcf86cd799439099', + replacementRoleId: '607f1f77bcf86cd799439100', + activityType: 'ROLE_ASSIGNED', + activityDescription: 'Reassigned safely', + activityByStaffUserId: '507f1f77bcf86cd799439013', + }); + }); + Then('the conditional update should report no change', () => { + expect(updateApplied).toBe(false); + }); + }); + Scenario('Creating a new staff user instance', ({ Given, When, Then, And }) => { Given('valid parameters for a new staff user', () => { // Already set up diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.repository.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.repository.ts index 4660ba5a7..a66f17f4d 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.repository.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.repository.ts @@ -1,8 +1,8 @@ +import { NotFoundError } from '@cellix/domain-seedwork/repository'; import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; - +import type { StaffUser } from '@ocom/data-sources-mongoose-models/user/staff-user'; import { Domain } from '@ocom/domain'; import type { StaffUserDomainAdapter } from './staff-user.domain-adapter.ts'; -import type { StaffUser } from '@ocom/data-sources-mongoose-models/user/staff-user'; type StaffUserDocument = StaffUser; type StaffUserAggregate = Domain.Contexts.User.StaffUser.StaffUser; @@ -13,10 +13,18 @@ export class StaffUserRepository extends MongooseSeedwork.MongoRepositoryBase { + const staffUser = await this.model.findById(id).session(this.session).exec(); + if (!staffUser) { + throw new NotFoundError(`StaffUser with id ${id} not found`); + } + return this.typeConverter.toDomain(staffUser, this.passport); + } + async getById(id: string): Promise { const staffUser = await this.model.findById(id).populate('role').exec(); if (!staffUser) { - throw new Error(`StaffUser with id ${id} not found`); + throw new NotFoundError(`StaffUser with id ${id} not found`); } return this.typeConverter.toDomain(staffUser, this.passport); } @@ -24,11 +32,81 @@ export class StaffUserRepository extends MongooseSeedwork.MongoRepositoryBase { const staffUser = await this.model.findOne({ externalId }).populate('role').exec(); if (!staffUser) { - throw new Error(`StaffUser with externalId ${externalId} not found`); + throw new NotFoundError(`StaffUser with externalId ${externalId} not found`); } return this.typeConverter.toDomain(staffUser, this.passport); } + async getAllAssignedToRole(roleId: string): Promise { + const staffUsers = await this.model.find({ role: roleId }).session(this.session).exec(); + return staffUsers.map((staffUser) => this.typeConverter.toDomain(staffUser, this.passport)); + } + + async getAssignedUserIdsToRoleBatch(roleId: string, limit: number): Promise { + const staffUsers = await this.model.find({ role: roleId }, { _id: 1 }).limit(limit).session(this.session).exec(); + return staffUsers.map((staffUser) => String(staffUser.id)); + } + + async getAssignedRoleIds(roleIds: string[]): Promise { + if (roleIds.length === 0) { + return []; + } + const staffUsers = await this.model + .find( + { + role: { + $in: roleIds.map((roleId) => new MongooseSeedwork.ObjectId(roleId)), + }, + }, + { role: 1 }, + ) + .session(this.session) + .exec(); + return [...new Set(staffUsers.flatMap((staffUser) => (staffUser.role ? [String(staffUser.role)] : [])))]; + } + + async setRoleIfCurrent(command: Domain.Contexts.User.StaffUser.SetStaffUserRoleIfCurrentCommand): Promise { + const now = new Date(); + const roleUpdate = command.replacementRoleId + ? { + $set: { + role: new MongooseSeedwork.ObjectId(command.replacementRoleId), + }, + } + : { + $unset: { + role: 1, + }, + }; + const updated = await this.model + .findOneAndUpdate( + { + _id: new MongooseSeedwork.ObjectId(command.staffUserId), + role: new MongooseSeedwork.ObjectId(command.expectedCurrentRoleId), + ...(command.expectedUpdatedAt ? { updatedAt: command.expectedUpdatedAt } : {}), + }, + { + ...roleUpdate, + $push: { + activityLog: { + activityType: command.activityType, + activityDescription: command.activityDescription, + activityBy: new MongooseSeedwork.ObjectId(command.activityByStaffUserId), + createdAt: now, + updatedAt: now, + }, + }, + }, + { + session: this.session, + new: false, + runValidators: true, + }, + ) + .exec(); + return updated !== null; + } + getNewInstance(externalId: string, firstName: string, lastName: string, email: string): Promise { const adapter = this.typeConverter.toAdapter(new this.model()); adapter.tags = []; diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-role/features/staff-role.read-repository.feature b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/features/staff-role.read-repository.feature index f86998055..5e61b08ec 100644 --- a/packages/ocom/persistence/src/datasources/readonly/user/staff-role/features/staff-role.read-repository.feature +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/features/staff-role.read-repository.feature @@ -17,6 +17,7 @@ Feature: StaffRoleReadRepository When I call getAll Then I should receive an array of StaffRoleEntityReference objects And the converter toDomain should have been called for each document + And logically deleted roles should be excluded from the lookup Scenario: getAll returns an empty array when no documents exist Given no StaffRole documents exist in the collection @@ -28,6 +29,7 @@ Feature: StaffRoleReadRepository When I call getById with "role-001" Then I should receive a StaffRoleEntityReference object And the converter toDomain should have been called with the document and passport + And logically deleted roles should be excluded from the id lookup Scenario: getById returns null when no document is found Given no StaffRole document exists with id "missing-id" diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.test.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.test.ts index 5b586d7ae..035c84dd5 100644 --- a/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.test.ts +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.test.ts @@ -50,7 +50,7 @@ function makeMockModelFindById(doc: StaffRole | null) { find: vi.fn().mockReturnValue({ exec: vi.fn().mockResolvedValue([]), }), - findById: vi.fn().mockReturnValue({ + findOne: vi.fn().mockReturnValue({ exec: vi.fn().mockResolvedValue(doc), }), } as unknown as StaffRoleModelType; @@ -137,6 +137,10 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { expect(mockConverter.toDomain).toHaveBeenCalledWith(mockDoc, passport); expect(mockConverter.toDomain).toHaveBeenCalledWith(secondDoc, passport); }); + And('logically deleted roles should be excluded from the lookup', () => { + const model = models.StaffRole as unknown as { find: ReturnType }; + expect(model.find).toHaveBeenCalledWith({ 'deletion.deletedAt': { $exists: false } }); + }); }); Scenario('getAll returns an empty array when no documents exist', ({ Given, When, Then }) => { @@ -168,6 +172,10 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { And('the converter toDomain should have been called with the document and passport', () => { expect(mockConverter.toDomain).toHaveBeenCalledWith(mockDoc, passport); }); + And('logically deleted roles should be excluded from the id lookup', () => { + const model = models.StaffRole as unknown as { findOne: ReturnType }; + expect(model.findOne).toHaveBeenCalledWith({ _id: 'role-001', 'deletion.deletedAt': { $exists: false } }); + }); }); Scenario('getById returns null when no document is found', ({ Given, When, Then }) => { diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.ts index b8b2fdf43..0b2846cb1 100644 --- a/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.ts +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.ts @@ -23,12 +23,12 @@ class StaffRoleReadRepositoryImpl implements StaffRoleReadRepository { } async getAll(): Promise { - const docs = await this.model.find({}).exec(); + const docs = await this.model.find({ 'deletion.deletedAt': { $exists: false } }).exec(); return docs.map((doc) => this.converter.toDomain(doc, this.passport)); } async getById(id: string): Promise { - const doc = await this.model.findById(id).exec(); + const doc = await this.model.findOne({ _id: id, 'deletion.deletedAt': { $exists: false } }).exec(); if (!doc) { return null; } diff --git a/packages/ocom/ui-staff-route-user-management/.storybook/preview.tsx b/packages/ocom/ui-staff-route-user-management/.storybook/preview.tsx index 2f9935493..93750e37d 100644 --- a/packages/ocom/ui-staff-route-user-management/.storybook/preview.tsx +++ b/packages/ocom/ui-staff-route-user-management/.storybook/preview.tsx @@ -9,10 +9,7 @@ export const decorators: Decorator[] = [ const apolloMocks = context.parameters?.apolloMocks ?? []; return ( - + diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.test.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.test.tsx new file mode 100644 index 000000000..2669e5386 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.test.tsx @@ -0,0 +1,163 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { StaffRoleCreate } from './staff-role-create.tsx'; + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +class ResizeObserverStub { + observe(): void { + // intentional no-op for jsdom + } + unobserve(): void { + // intentional no-op for jsdom + } + disconnect(): void { + // intentional no-op for jsdom + } +} +window.ResizeObserver = window.ResizeObserver ?? (ResizeObserverStub as unknown as typeof ResizeObserver); + +const findButtonByText = (rendered: HTMLElement, text: string): HTMLButtonElement | undefined => Array.from(rendered.querySelectorAll('button')).find((button) => (button.textContent ?? '').trim() === text); + +const clickButton = (button: HTMLButtonElement): void => { + act(() => { + button.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +}; + +describe('StaffRoleCreate', () => { + let container!: HTMLDivElement; + let root!: ReturnType; + + const defaultProps = { + onSubmit: vi.fn(), + onCancel: vi.fn(), + availableEnterpriseAppRoles: ['Staff.CaseManager', 'Staff.TechAdmin'], + }; + + const renderComponent = (props: Partial[0]> = {}): HTMLDivElement => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root.render( + , + ); + }); + return container; + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + act(() => { + root?.unmount(); + }); + container?.remove(); + }); + + describe('titles', () => { + it('renders the create title in create mode', () => { + const rendered = renderComponent(); + expect(rendered.textContent).toContain('Create Staff Role'); + }); + + it('renders the edit title in edit mode', () => { + const rendered = renderComponent({ mode: 'edit' }); + expect(rendered.textContent).toContain('Edit Staff Role'); + }); + + it('renders a details title and hides editing controls in read-only mode', () => { + const rendered = renderComponent({ mode: 'edit', editable: false, showDelete: true, onDelete: vi.fn() }); + expect(rendered.textContent).toContain('Staff Role Details'); + expect(findButtonByText(rendered, 'Update Role')).toBeUndefined(); + expect(findButtonByText(rendered, 'Delete Role')).toBeDefined(); + expect(Array.from(rendered.querySelectorAll('input')).every((input) => input.disabled)).toBe(true); + }); + }); + + describe('delete action visibility', () => { + it('renders the delete action for a deletable role in edit mode', () => { + const rendered = renderComponent({ mode: 'edit', showDelete: true, onDelete: vi.fn() }); + expect(findButtonByText(rendered, 'Delete Role')).toBeDefined(); + }); + + it('does not render the delete action when showDelete is false', () => { + const rendered = renderComponent({ mode: 'edit', showDelete: false, onDelete: vi.fn() }); + expect(findButtonByText(rendered, 'Delete Role')).toBeUndefined(); + }); + + it('does not render the delete action in create mode even when showDelete is set', () => { + const rendered = renderComponent({ mode: 'create', showDelete: true, onDelete: vi.fn() }); + expect(findButtonByText(rendered, 'Delete Role')).toBeUndefined(); + }); + + it('renders the delete action as a danger button', () => { + const rendered = renderComponent({ mode: 'edit', showDelete: true, onDelete: vi.fn() }); + const deleteButton = findButtonByText(rendered, 'Delete Role'); + expect(deleteButton?.className).toContain('ant-btn-dangerous'); + }); + }); + + describe('delete confirmation', () => { + it('shows a confirmation explaining reassignment to the matching default role', () => { + const rendered = renderComponent({ mode: 'edit', showDelete: true, onDelete: vi.fn() }); + const deleteButton = findButtonByText(rendered, 'Delete Role'); + expect(deleteButton).toBeDefined(); + clickButton(deleteButton as HTMLButtonElement); + + const confirmation = document.querySelector('.ant-popconfirm'); + expect(confirmation).not.toBeNull(); + expect(confirmation?.textContent).toMatch(/reassigned/i); + expect(confirmation?.textContent).toMatch(/default role/i); + }); + + it('calls onDelete when the confirmation is accepted', () => { + const onDelete = vi.fn(); + const rendered = renderComponent({ mode: 'edit', showDelete: true, onDelete }); + clickButton(findButtonByText(rendered, 'Delete Role') as HTMLButtonElement); + + const confirmButton = Array.from(document.querySelectorAll('.ant-popconfirm-buttons button')).find((button) => (button.textContent ?? '').trim() === 'Delete'); + expect(confirmButton).toBeDefined(); + clickButton(confirmButton as HTMLButtonElement); + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it('does not call onDelete when the confirmation is cancelled', () => { + const onDelete = vi.fn(); + const rendered = renderComponent({ mode: 'edit', showDelete: true, onDelete }); + clickButton(findButtonByText(rendered, 'Delete Role') as HTMLButtonElement); + + const cancelButton = Array.from(document.querySelectorAll('.ant-popconfirm-buttons button')).find((button) => (button.textContent ?? '').trim() === 'Cancel'); + expect(cancelButton).toBeDefined(); + clickButton(cancelButton as HTMLButtonElement); + expect(onDelete).not.toHaveBeenCalled(); + }); + }); + + describe('deletion in progress', () => { + it('shows the delete button in a loading state while deleting', () => { + const rendered = renderComponent({ mode: 'edit', showDelete: true, onDelete: vi.fn(), deleting: true }); + const deleteButton = findButtonByText(rendered, 'Delete Role'); + expect(deleteButton?.className).toContain('ant-btn-loading'); + }); + }); +}); diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.tsx index f4613977b..2574ec5f5 100644 --- a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.tsx +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.tsx @@ -1,4 +1,4 @@ -import { Button, Checkbox, Divider, Form, Input, Select, Space, Typography } from 'antd'; +import { Button, Checkbox, Divider, Form, Input, Popconfirm, Select, Space, Typography } from 'antd'; import type React from 'react'; const { Title } = Typography; @@ -38,6 +38,10 @@ interface StaffRoleCreateProps { showTechAdminPermissions?: boolean; initialValues?: Partial; mode?: 'create' | 'edit'; + showDelete?: boolean; + onDelete?: () => void; + deleting?: boolean; + editable?: boolean; } type PermissionFieldKey = keyof Omit; @@ -143,7 +147,19 @@ const DEFAULT_VALUES: StaffRoleFormValues = { canSendQueueMessages: false, }; -export const StaffRoleCreate: React.FC = ({ onSubmit, onCancel, loading, availableEnterpriseAppRoles, showTechAdminPermissions, initialValues, mode = 'create' }) => { +export const StaffRoleCreate: React.FC = ({ + onSubmit, + onCancel, + loading, + availableEnterpriseAppRoles, + showTechAdminPermissions, + initialValues, + mode = 'create', + showDelete, + onDelete, + deleting, + editable = true, +}) => { const [form] = Form.useForm(); const defaultValues: StaffRoleFormValues = { @@ -172,7 +188,32 @@ export const StaffRoleCreate: React.FC = ({ onSubmit, onCa size="large" style={{ width: '100%', maxWidth: 720 }} > - {isEdit ? 'Edit Staff Role' : 'Create Staff Role'} +
+ + {isEdit ? (editable ? 'Edit Staff Role' : 'Staff Role Details') : 'Create Staff Role'} + + {isEdit && showDelete && onDelete && ( + trigger.parentElement ?? document.body} + > + + + )} +
= ({ onSubmit, onCa label="Role Name" rules={[{ required: true, message: 'Role name is required' }]} > - + = ({ onSubmit, onCa rules={[{ required: true, message: 'Enterprise app role is required' }]} >