diff --git a/apps/ui-community/mock-oidc.users.json b/apps/ui-community/mock-oidc.users.json
index f5518cba9..5215d6985 100644
--- a/apps/ui-community/mock-oidc.users.json
+++ b/apps/ui-community/mock-oidc.users.json
@@ -34,5 +34,29 @@
"family_name": "Owner",
"tid": "test-tenant-id"
}
+ },
+ {
+ "username": "member@test.example",
+ "sub": "aaaaaaaa-bbbb-1ccc-9ddd-eeeeeeeeee02",
+ "password": "password",
+ "oidcConfigName": "end-user",
+ "claims": {
+ "email": "member@test.example",
+ "given_name": "Test",
+ "family_name": "Member",
+ "tid": "test-tenant-id"
+ }
+ },
+ {
+ "username": "other.owner@test.example",
+ "sub": "aaaaaaaa-bbbb-1ccc-9ddd-eeeeeeeeee03",
+ "password": "password",
+ "oidcConfigName": "end-user",
+ "claims": {
+ "email": "other.owner@test.example",
+ "given_name": "Other",
+ "family_name": "Owner",
+ "tid": "test-tenant-id"
+ }
}
]
diff --git a/apps/ui-community/package.json b/apps/ui-community/package.json
index 1055de59f..7c3edfd4c 100644
--- a/apps/ui-community/package.json
+++ b/apps/ui-community/package.json
@@ -27,6 +27,7 @@
"@ocom/local-dev-config": "workspace:*",
"@ocom/ui-community-route-accounts": "workspace:*",
"@ocom/ui-community-route-admin": "workspace:*",
+ "@ocom/ui-community-route-member": "workspace:*",
"@ocom/ui-community-route-root": "workspace:*",
"@ocom/ui-community-shared": "workspace:*",
"antd": "catalog:",
diff --git a/apps/ui-community/src/App.tsx b/apps/ui-community/src/App.tsx
index 83cd0fd0f..dc515fca5 100644
--- a/apps/ui-community/src/App.tsx
+++ b/apps/ui-community/src/App.tsx
@@ -1,6 +1,7 @@
import { RequireAuth } from '@cellix/ui-core';
import { Accounts } from '@ocom/ui-community-route-accounts';
import { Admin } from '@ocom/ui-community-route-admin';
+import { Member } from '@ocom/ui-community-route-member';
import { Root } from '@ocom/ui-community-route-root';
import { Route, Routes } from 'react-router-dom';
import './App.css';
@@ -31,6 +32,10 @@ export default function App() {
path="/:communityId/admin/:memberId/*"
element={}
/>
+ }
+ />
);
diff --git a/codegen.yml b/codegen.yml
index 201ef3192..f056e1fb2 100644
--- a/codegen.yml
+++ b/codegen.yml
@@ -98,6 +98,21 @@ generates:
- typescript-operations
- typed-document-node
+# UI community member-route client types
+ './packages/ocom/ui-community-route-member/src/generated.tsx':
+ documents:
+ - './packages/ocom/ui-community-route-member/src/**/**.graphql'
+ config:
+ withHooks: true
+ withHOC: false
+ withComponent: false
+ useTypeImports: true
+ enumsAsTypes: true
+ plugins:
+ - typescript
+ - typescript-operations
+ - typed-document-node
+
# UI community client types
'./packages/ocom/ui-community-route-accounts/src/generated.tsx':
documents:
diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/notes/member-property-notes.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/notes/member-property-notes.ts
new file mode 100644
index 000000000..a22b19645
--- /dev/null
+++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/notes/member-property-notes.ts
@@ -0,0 +1,21 @@
+type MemberPropertyOperationStatus = 'SUCCESS' | 'REJECTED' | 'ERROR' | null;
+type MemberPropertyReadStatus = 'FOUND' | 'MISSING' | 'ERROR' | null;
+
+/** Scenario-local state for member Property API behavior. */
+export interface MemberPropertyNotes {
+ activeCommunityId: string;
+ activeCommunityName: string;
+ actingMemberId: string;
+ managerMemberId: string;
+ foreignMemberId: string | null;
+ knownPropertyIds: Record;
+ listedPropertyNames: string[];
+ directoryStatus: MemberPropertyOperationStatus;
+ directoryError: string | null;
+ lastOperationStatus: MemberPropertyOperationStatus;
+ lastOperationError: string | null;
+ lastReadStatus: MemberPropertyReadStatus;
+ lastReadError: string | null;
+ lastReadPropertyId: string | null;
+ baselinePropertyNames: string[];
+}
diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/questions/member-property-state.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/questions/member-property-state.ts
new file mode 100644
index 000000000..159dde6d5
--- /dev/null
+++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/questions/member-property-state.ts
@@ -0,0 +1,37 @@
+import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql';
+import { type Actor, type AnswersQuestions, notes, Question, type UsesAbilities } from '@serenity-js/core';
+import { PROPERTIES_BY_COMMUNITY_ID_QUERY, type PropertyResult } from '../../../shared/graphql/property-operations.ts';
+import type { MemberPropertyNotes } from '../notes/member-property-notes.ts';
+
+export const MemberPropertyOperationStatus = () => Question.about('the member Property operation status', async (actor) => await actor.answer(notes().get('lastOperationStatus')));
+
+export const MemberPropertyOperationError = () => Question.about('the member Property operation error', async (actor) => await actor.answer(notes().get('lastOperationError')));
+
+export const MemberPropertyDirectoryStatus = () => Question.about('the member Property directory status', async (actor) => await actor.answer(notes().get('directoryStatus')));
+
+export const MemberPropertyDirectoryNames = () => Question.about('the member Property directory names', async (actor) => await actor.answer(notes().get('listedPropertyNames')));
+
+export const MemberPropertyReadStatus = () => Question.about('the member Property read status', async (actor) => await actor.answer(notes().get('lastReadStatus')));
+
+export const MemberPropertyReadError = () => Question.about('the member Property read error', async (actor) => await actor.answer(notes().get('lastReadError')));
+
+/** Reads a property by name from the actor's active member Property community. */
+export class MemberPropertyNamed extends Question> {
+ static called(propertyName: string): MemberPropertyNamed {
+ return new MemberPropertyNamed(propertyName);
+ }
+
+ private constructor(private readonly propertyName: string) {
+ super(`the member Property named "${propertyName}"`);
+ }
+
+ override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise {
+ const communityId = await actor.answer(notes().get('activeCommunityId'));
+ if (!communityId) {
+ throw new Error('No active member Property community is available to query a property by name');
+ }
+ const response = await GraphQLClient.as(actor as unknown as Actor).execute(PROPERTIES_BY_COMMUNITY_ID_QUERY, { communityId });
+ const properties = response.data.propertiesByCommunityId as PropertyResult[];
+ return properties.find((property) => property.propertyName === this.propertyName);
+ }
+}
diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/step-definitions/index.ts
new file mode 100644
index 000000000..6da6a6691
--- /dev/null
+++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/step-definitions/index.ts
@@ -0,0 +1 @@
+import './member-property.steps.ts';
diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/step-definitions/member-property.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/step-definitions/member-property.steps.ts
new file mode 100644
index 000000000..869e84ddb
--- /dev/null
+++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/step-definitions/member-property.steps.ts
@@ -0,0 +1,371 @@
+import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table';
+import { type DataTable, Given, Then, When } from '@cucumber/cucumber';
+import { type Actor, actorCalled, notes } from '@serenity-js/core';
+import type { MemberPropertyNotes } from '../notes/member-property-notes.ts';
+import {
+ MemberPropertyDirectoryNames,
+ MemberPropertyDirectoryStatus,
+ MemberPropertyNamed,
+ MemberPropertyOperationError,
+ MemberPropertyOperationStatus,
+ MemberPropertyReadError,
+ MemberPropertyReadStatus,
+} from '../questions/member-property-state.ts';
+import { ArrangeMemberProperty, MakeForeignMemberAvailable } from '../tasks/arrange-member-property.ts';
+import { BecomeSeparateOwnPropertyMember } from '../tasks/become-separate-own-property-member.ts';
+import { EstablishMemberPropertyCommunity } from '../tasks/establish-member-property-community.ts';
+import { memberPropertyCommunityOf, memberPropertyIdKnownTo } from '../tasks/member-property-context.ts';
+import {
+ AttemptMemberPropertyCreateWithOwner,
+ CreateMemberProperty,
+ DeleteMemberProperty,
+ type MemberPropertyUpdateInput,
+ OpenMemberPropertyDirectory,
+ ReadMemberProperty,
+ UpdateMemberProperty,
+} from '../tasks/member-property-operations.ts';
+import { BecomeMemberPropertyManager, BecomeMemberPropertyVisitor, type MemberPropertyVisitor } from '../tasks/member-property-personas.ts';
+
+const MAYA = 'Maya';
+const INSPECTOR = 'MemberPropertyInspector';
+const UNKNOWN_PROPERTY_ID = 'ffffffffffffffffffffffff';
+const SCHEMA_LEVEL_ERROR = /cannot query field|unknown argument|unknown type|is not defined by type/i;
+
+let lastActorName = MAYA;
+
+async function mayaCommunity() {
+ return await memberPropertyCommunityOf(actorCalled(MAYA));
+}
+
+async function managerInspector(): Promise {
+ const actor = actorCalled(INSPECTOR);
+ await actor.attemptsTo(BecomeMemberPropertyManager.inCommunity(await mayaCommunity()));
+ return actor;
+}
+
+async function propertyIdFor(actor: Actor, propertyName: string): Promise {
+ const knownForActor = await memberPropertyIdKnownTo(actor, propertyName);
+ if (knownForActor) {
+ return knownForActor;
+ }
+ const knownForMaya = await memberPropertyIdKnownTo(actorCalled(MAYA), propertyName);
+ if (knownForMaya) {
+ return knownForMaya;
+ }
+ throw new Error(`No member Property id is recorded for "${propertyName}". Did a fixture arrange it first?`);
+}
+
+async function ownerIdFor(actor: Actor, value: string): Promise {
+ if (value === 'self') {
+ const memberId = await actor.answer(notes().get('actingMemberId'));
+ if (!memberId) {
+ throw new Error('No acting member id is available for the supplied self ownerId');
+ }
+ return memberId;
+ }
+ if (value === 'foreign') {
+ const foreignMemberId = await actor.answer(notes().get('foreignMemberId'));
+ if (!foreignMemberId) {
+ throw new Error('No foreign member id is available for the supplied ownerId');
+ }
+ return foreignMemberId;
+ }
+ throw new Error(`Unsupported supplied member Property owner id selector "${value}"`);
+}
+
+async function updateInputFor(actor: Actor, dataTable: DataTable): Promise {
+ const raw = GherkinDataTable.from(dataTable).rowsHash>();
+ const input: MemberPropertyUpdateInput = {};
+ if (raw.propertyName !== undefined) {
+ input.propertyName = raw.propertyName;
+ }
+ if (raw.propertyType !== undefined) {
+ input.propertyType = raw.propertyType.trim() === '' ? null : raw.propertyType;
+ }
+ if (raw.ownerId !== undefined) {
+ input.ownerId = raw.ownerId === 'clear' ? null : await ownerIdFor(actor, raw.ownerId);
+ }
+ if (raw.listedInDirectory !== undefined) {
+ if (raw.listedInDirectory !== 'true' && raw.listedInDirectory !== 'false') {
+ throw new Error(`The listedInDirectory value must be "true" or "false", not "${raw.listedInDirectory}"`);
+ }
+ input.listedInDirectory = raw.listedInDirectory === 'true';
+ }
+ return input;
+}
+
+async function expectOperationSucceeded(): Promise {
+ const actor = actorCalled(lastActorName);
+ const status = await actor.answer(MemberPropertyOperationStatus());
+ if (status !== 'SUCCESS') {
+ const error = await actor.answer(MemberPropertyOperationError());
+ throw new Error(`Expected the member Property operation to succeed, but it was "${status}"${error ? `: ${error}` : ''}`);
+ }
+}
+
+async function expectOperationRejected(): Promise {
+ const actor = actorCalled(lastActorName);
+ const status = await actor.answer(MemberPropertyOperationStatus());
+ if (status === 'SUCCESS') {
+ throw new Error('Expected the member Property operation to be rejected, but it succeeded');
+ }
+ const error = await actor.answer(MemberPropertyOperationError());
+ if (!error) {
+ throw new Error('Expected a rejected member Property operation to record an error');
+ }
+ if (SCHEMA_LEVEL_ERROR.test(error)) {
+ throw new Error(`Member Property behavior could not be verified because the GraphQL schema rejected the operation: "${error}"`);
+ }
+ return error;
+}
+
+async function expectDirectoryContains(propertyName: string, expected: boolean): Promise {
+ const actor = actorCalled(lastActorName);
+ const status = await actor.answer(MemberPropertyDirectoryStatus());
+ if (status !== 'SUCCESS') {
+ const error = await actor.answer(MemberPropertyOperationError());
+ throw new Error(`Expected the member Property directory to load before checking "${propertyName}", but it was "${status}"${error ? `: ${error}` : ''}`);
+ }
+ const names = await actor.answer(MemberPropertyDirectoryNames());
+ const found = names.includes(propertyName);
+ if (found !== expected) {
+ throw new Error(`Expected the member Property directory to ${expected ? 'include' : 'exclude'} "${propertyName}", but it listed: ${names.join(', ') || 'none'}`);
+ }
+}
+
+async function expectDetailsAvailable(): Promise {
+ const actor = actorCalled(lastActorName);
+ const status = await actor.answer(MemberPropertyReadStatus());
+ if (status !== 'FOUND') {
+ const error = await actor.answer(MemberPropertyReadError());
+ throw new Error(`Expected the member Property details to be available, but they were "${status ?? 'not read'}"${error ? `: ${error}` : ''}`);
+ }
+}
+
+async function inspectProperty(propertyName: string) {
+ const inspector = await managerInspector();
+ return await inspector.answer(MemberPropertyNamed.called(propertyName));
+}
+
+Given('{word} is an accepted own-property member in a member Property community', async (actorName: string) => {
+ lastActorName = actorName;
+ await actorCalled(actorName).attemptsTo(EstablishMemberPropertyCommunity.forOwnPropertyMember());
+});
+
+Given('a foreign same-community member is available for member Property ownership', async () => {
+ await actorCalled(MAYA).attemptsTo(MakeForeignMemberAvailable.inCurrentCommunity());
+});
+
+Given('a foreign same-community member owns the member Property {string}', async (propertyName: string) => {
+ const actor = actorCalled(MAYA);
+ await actor.attemptsTo(MakeForeignMemberAvailable.inCurrentCommunity(), ArrangeMemberProperty.ownedByForeignMember(propertyName));
+});
+
+Given('{word} owns the member Property {string}', async (actorName: string, propertyName: string) => {
+ lastActorName = actorName;
+ await actorCalled(actorName).attemptsTo(ArrangeMemberProperty.ownedByCurrentMember(propertyName));
+});
+
+Given("{word} is the property manager of Maya's member Property community", async (actorName: string) => {
+ lastActorName = actorName;
+ await actorCalled(actorName).attemptsTo(BecomeMemberPropertyManager.inCommunity(await mayaCommunity()));
+});
+
+Given("{word} is a {string} member Property API visitor in Maya's community", async (actorName: string, visitor: string) => {
+ if (!['no-permission', 'no-role', 'created', 'rejected', 'guest'].includes(visitor)) {
+ throw new Error(`Unsupported member Property API visitor "${visitor}"`);
+ }
+ lastActorName = actorName;
+ await actorCalled(actorName).attemptsTo(BecomeMemberPropertyVisitor.inCommunity(visitor as MemberPropertyVisitor, await mayaCommunity()));
+});
+
+Given('{word} is an accepted own-property member of a separate member Property community', async (actorName: string) => {
+ lastActorName = actorName;
+ await actorCalled(actorName).attemptsTo(BecomeSeparateOwnPropertyMember.inNewCommunity());
+});
+
+When('{word} opens the member Property directory', async (actorName: string) => {
+ lastActorName = actorName;
+ await actorCalled(actorName).attemptsTo(OpenMemberPropertyDirectory.currentCommunity());
+});
+
+When('{word} creates a member Property named {string}', async (actorName: string, propertyName: string) => {
+ lastActorName = actorName;
+ await actorCalled(actorName).attemptsTo(CreateMemberProperty.named(propertyName));
+});
+
+When('{word} attempts to create a member Property named {string}', async (actorName: string, propertyName: string) => {
+ lastActorName = actorName;
+ await actorCalled(actorName).attemptsTo(CreateMemberProperty.named(propertyName));
+});
+
+When('{word} sets the member Property {string} listing flag {string} to {string}', async (actorName: string, propertyName: string, flag: string, value: string) => {
+ if (flag !== 'listedInDirectory') {
+ throw new Error(`Only the representative member-editable flag "listedInDirectory" is supported, not "${flag}"`);
+ }
+ if (value !== 'true' && value !== 'false') {
+ throw new Error(`The listing flag value must be "true" or "false", not "${value}"`);
+ }
+ lastActorName = actorName;
+ const actor = actorCalled(actorName);
+ await actor.attemptsTo(UpdateMemberProperty.with(propertyName, await propertyIdFor(actor, propertyName), { listedInDirectory: value === 'true' }));
+});
+
+When('{word} attempts to set the member Property {string} listing flag {string} to {string}', async (actorName: string, propertyName: string, flag: string, value: string) => {
+ if (flag !== 'listedInDirectory') {
+ throw new Error(`Only the representative member-editable flag "listedInDirectory" is supported, not "${flag}"`);
+ }
+ if (value !== 'true' && value !== 'false') {
+ throw new Error(`The listing flag value must be "true" or "false", not "${value}"`);
+ }
+ lastActorName = actorName;
+ const actor = actorCalled(actorName);
+ await actor.attemptsTo(UpdateMemberProperty.with(propertyName, await propertyIdFor(actor, propertyName), { listedInDirectory: value === 'true' }));
+});
+
+When('{word} attempts to update the member Property {string} with:', async (actorName: string, propertyName: string, dataTable: DataTable) => {
+ lastActorName = actorName;
+ const actor = actorCalled(actorName);
+ await actor.attemptsTo(UpdateMemberProperty.with(propertyName, await propertyIdFor(actor, propertyName), await updateInputFor(actor, dataTable)));
+});
+
+When('{word} attempts to delete the member Property {string}', async (actorName: string, propertyName: string) => {
+ lastActorName = actorName;
+ const actor = actorCalled(actorName);
+ await actor.attemptsTo(DeleteMemberProperty.identifiedAs(propertyName, await propertyIdFor(actor, propertyName)));
+});
+
+When('{word} attempts to create a member Property named {string} with supplied owner id {string}', async (actorName: string, propertyName: string, owner: string) => {
+ lastActorName = actorName;
+ const actor = actorCalled(actorName);
+ await actor.attemptsTo(AttemptMemberPropertyCreateWithOwner.named(propertyName, await ownerIdFor(actor, owner)));
+});
+
+When("{word} attempts to list Maya's member Property directory", async (actorName: string) => {
+ lastActorName = actorName;
+ await actorCalled(actorName).attemptsTo(OpenMemberPropertyDirectory.community((await mayaCommunity()).communityId));
+});
+
+When("{word} attempts to read the member Property {string} from Maya's community", async (actorName: string, propertyName: string) => {
+ lastActorName = actorName;
+ const actor = actorCalled(actorName);
+ await actor.attemptsTo(ReadMemberProperty.identifiedAs(propertyName, await propertyIdFor(actor, propertyName)));
+});
+
+When("{word} reads the member Property {string} from Maya's community", async (actorName: string, propertyName: string) => {
+ lastActorName = actorName;
+ const actor = actorCalled(actorName);
+ await actor.attemptsTo(ReadMemberProperty.identifiedAs(propertyName, await propertyIdFor(actor, propertyName)));
+});
+
+When('{word} attempts to update an unknown member Property', async (actorName: string) => {
+ lastActorName = actorName;
+ await actorCalled(actorName).attemptsTo(UpdateMemberProperty.with('Unknown Member Property', UNKNOWN_PROPERTY_ID, { listedInDirectory: true }));
+});
+
+When('{word} deletes the member Property {string}', async (actorName: string, propertyName: string) => {
+ lastActorName = actorName;
+ const actor = actorCalled(actorName);
+ await actor.attemptsTo(DeleteMemberProperty.identifiedAs(propertyName, await propertyIdFor(actor, propertyName)));
+});
+
+Then('the member Property operation succeeds', async () => {
+ await expectOperationSucceeded();
+});
+
+Then('the member Property operation is rejected', async () => {
+ await expectOperationRejected();
+});
+
+Then('the member Property error is exactly {string}', async (expectedError: string) => {
+ const actual = await actorCalled(lastActorName).answer(MemberPropertyOperationError());
+ if (actual !== expectedError) {
+ throw new Error(`Expected the member Property error to be exactly "${expectedError}", but got "${actual ?? 'none'}"`);
+ }
+});
+
+Then('the member Property directory includes {string}', async (propertyName: string) => {
+ await expectDirectoryContains(propertyName, true);
+});
+
+Then('the member Property directory does not include {string}', async (propertyName: string) => {
+ await expectDirectoryContains(propertyName, false);
+});
+
+Then('the member Property {string} is persisted with {word} as its owner', async (propertyName: string, ownerActorName: string) => {
+ const property = await inspectProperty(propertyName);
+ const expectedOwnerId = await actorCalled(ownerActorName).answer(notes().get('actingMemberId'));
+ if (!property?.owner || property.owner.id !== expectedOwnerId) {
+ throw new Error(`Expected the member Property "${propertyName}" to be owned by ${ownerActorName} (${expectedOwnerId}), but it is owned by ${property?.owner?.id ?? 'nobody'}`);
+ }
+});
+
+Then('the member Property {string} has listing flag {string} set to {string}', async (propertyName: string, flag: string, expectedValue: string) => {
+ if (flag !== 'listedInDirectory') {
+ throw new Error(`Only the representative listing flag "listedInDirectory" can be asserted, not "${flag}"`);
+ }
+ const property = await inspectProperty(propertyName);
+ const actual = property?.listedInDirectory;
+ if (actual !== (expectedValue === 'true')) {
+ throw new Error(`Expected the member Property "${propertyName}" to have ${flag}=${expectedValue}, but got ${actual ?? 'missing property'}`);
+ }
+});
+
+Then('{word} sees the member Property {string} listing flag {string} set to {string}', async (actorName: string, propertyName: string, flag: string, expectedValue: string) => {
+ if (flag !== 'listedInDirectory') {
+ throw new Error(`Only the representative listing flag "listedInDirectory" can be asserted, not "${flag}"`);
+ }
+ const property = await actorCalled(actorName).answer(MemberPropertyNamed.called(propertyName));
+ if (property?.listedInDirectory !== (expectedValue === 'true')) {
+ throw new Error(`Expected ${actorName} to see ${flag}=${expectedValue} for "${propertyName}", but got ${property?.listedInDirectory ?? 'missing property'}`);
+ }
+});
+
+Then('the member Property {string} has no property type', async (propertyName: string) => {
+ const property = await inspectProperty(propertyName);
+ if (property?.propertyType !== null) {
+ throw new Error(`Expected the member Property "${propertyName}" to have no property type, but got "${property?.propertyType ?? 'missing property'}"`);
+ }
+});
+
+Then('the member Property {string} remains owned by {word}', async (propertyName: string, ownerActorName: string) => {
+ const property = await inspectProperty(propertyName);
+ const expectedOwnerId = await actorCalled(ownerActorName).answer(notes().get('actingMemberId'));
+ if (property?.owner?.id !== expectedOwnerId) {
+ throw new Error(`Expected the member Property "${propertyName}" to remain owned by ${ownerActorName}, but it is owned by ${property?.owner?.id ?? 'nobody'}`);
+ }
+});
+
+Then('the member Property {string} remains active', async (propertyName: string) => {
+ if (!(await inspectProperty(propertyName))) {
+ throw new Error(`Expected the member Property "${propertyName}" to remain active, but the manager cannot find it`);
+ }
+});
+
+Then("the member Property {string} remains in Maya's community", async (propertyName: string) => {
+ const property = await inspectProperty(propertyName);
+ const { communityId } = await mayaCommunity();
+ if (property?.community.id !== communityId) {
+ throw new Error(`Expected the member Property "${propertyName}" to remain in Maya's community "${communityId}", but it belongs to "${property?.community.id ?? 'no community'}"`);
+ }
+});
+
+Then('no member Property named {string} is created', async (propertyName: string) => {
+ const property = await inspectProperty(propertyName);
+ if (property) {
+ throw new Error(`Expected no member Property named "${propertyName}" to be created, but it has id ${property.id}`);
+ }
+});
+
+Then('the member Property details are unavailable', async () => {
+ const actor = actorCalled(lastActorName);
+ const status = await actor.answer(MemberPropertyReadStatus());
+ if (status !== 'MISSING') {
+ const error = await actor.answer(MemberPropertyReadError());
+ throw new Error(`Expected the member Property read to be non-disclosing and unavailable, but it was "${status}"${error ? `: ${error}` : ''}`);
+ }
+});
+
+Then('the member Property details are available', async () => {
+ await expectDetailsAvailable();
+});
diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/arrange-member-property.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/arrange-member-property.ts
new file mode 100644
index 000000000..7cb05d559
--- /dev/null
+++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/arrange-member-property.ts
@@ -0,0 +1,103 @@
+import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql';
+import { actors, END_USER_IDS } from '@ocom-verification/verification-shared/test-data';
+import { type Actor, notes, Task } from '@serenity-js/core';
+import { getActorContext, getActorToken, setActorContext, setActorToken, userTokenFor } from '../../../shared/abilities/actor-auth.ts';
+import { ProvisionMemberPropertyFixture as ProvisionMemberPropertyFixtureAbility } from '../../../shared/abilities/provision-member-property-fixture.ts';
+import { PROPERTY_FULL_CREATE_MUTATION, type PropertyFullMutationResult } from '../../../shared/graphql/property-field-operations.ts';
+import type { MemberPropertyNotes } from '../notes/member-property-notes.ts';
+import { memberPropertyCommunityOf, rememberMemberPropertyId } from './member-property-context.ts';
+
+const FOREIGN_MEMBER_NAME = 'Foreign Same-Community Owner';
+
+/**
+ * Adds a second accepted member to the current community for foreign-owner
+ * directory and authorization scenarios.
+ */
+export class MakeForeignMemberAvailable extends Task {
+ static inCurrentCommunity(): MakeForeignMemberAvailable {
+ return new MakeForeignMemberAvailable();
+ }
+
+ private constructor() {
+ super('makes a foreign same-community member available for property ownership');
+ }
+
+ async performAs(actor: Actor): Promise {
+ const community = await memberPropertyCommunityOf(actor);
+ const foreignMember = await ProvisionMemberPropertyFixtureAbility.as(actor).performAs(actor, {
+ communityId: community.communityId,
+ endUserId: END_USER_IDS.otherCommunityOwner,
+ memberName: FOREIGN_MEMBER_NAME,
+ firstName: actors.OtherCommunityOwner.givenName,
+ lastName: actors.OtherCommunityOwner.familyName,
+ accountStatus: 'ACCEPTED',
+ canEditOwnProperty: false,
+ canManageProperties: false,
+ });
+ await actor.attemptsTo(notes().set('foreignMemberId', foreignMember.memberId));
+ }
+}
+
+type MemberPropertyOwner = 'current-member' | 'foreign-member';
+
+/**
+ * Uses the established manager identity only to arrange an existing listing.
+ * The original actor identity is restored before the scenario action begins.
+ */
+export class ArrangeMemberProperty extends Task {
+ static ownedByCurrentMember(propertyName: string): ArrangeMemberProperty {
+ return new ArrangeMemberProperty(propertyName, 'current-member');
+ }
+
+ static ownedByForeignMember(propertyName: string): ArrangeMemberProperty {
+ return new ArrangeMemberProperty(propertyName, 'foreign-member');
+ }
+
+ private constructor(
+ private readonly propertyName: string,
+ private readonly owner: MemberPropertyOwner,
+ ) {
+ super(`arranges the member Property "${propertyName}"`);
+ }
+
+ async performAs(actor: Actor): Promise {
+ const community = await memberPropertyCommunityOf(actor);
+ const ownerId = await this.ownerId(actor);
+ const priorToken = getActorToken(actor.name);
+ const priorContext = getActorContext(actor.name);
+ setActorToken(actor.name, userTokenFor(actors.CommunityOwner.name));
+ setActorContext(actor.name, { memberId: community.managerMemberId, communityId: community.communityId });
+
+ try {
+ const response = await GraphQLClient.as(actor).execute(PROPERTY_FULL_CREATE_MUTATION, {
+ input: {
+ propertyName: this.propertyName,
+ ownerId,
+ },
+ });
+ const result = response.data.propertyCreate as PropertyFullMutationResult | undefined;
+ if (result?.status.success !== true || !result.property?.id) {
+ throw new Error(`Could not arrange the member Property "${this.propertyName}": ${result?.status.errorMessage ?? 'no property was returned'}`);
+ }
+ await rememberMemberPropertyId(actor, this.propertyName, result.property.id);
+ } finally {
+ setActorToken(actor.name, priorToken);
+ setActorContext(actor.name, priorContext ?? null);
+ }
+ }
+
+ private async ownerId(actor: Actor): Promise {
+ if (this.owner === 'current-member') {
+ const currentMemberId = await actor.answer(notes().get('actingMemberId'));
+ if (!currentMemberId) {
+ throw new Error('No current member id is available to arrange an owned member Property');
+ }
+ return currentMemberId;
+ }
+ const foreignMemberId = await actor.answer(notes().get('foreignMemberId'));
+ if (!foreignMemberId) {
+ throw new Error('No foreign member is available. Arrange a foreign same-community member before creating its property.');
+ }
+ return foreignMemberId;
+ }
+}
diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/become-separate-own-property-member.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/become-separate-own-property-member.ts
new file mode 100644
index 000000000..fd48b9daf
--- /dev/null
+++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/become-separate-own-property-member.ts
@@ -0,0 +1,44 @@
+import { actors, END_USER_IDS } from '@ocom-verification/verification-shared/test-data';
+import { type Actor, notes, Task } from '@serenity-js/core';
+import { setActorContext, setActorToken, userTokenFor } from '../../../shared/abilities/actor-auth.ts';
+import { ProvisionMemberPropertyFixture as ProvisionMemberPropertyFixtureAbility } from '../../../shared/abilities/provision-member-property-fixture.ts';
+import type { PropertyNotes } from '../../property/notes/property-notes.ts';
+import { BecomePropertyManager } from '../../property/tasks/become-property-manager.ts';
+import { initialiseMemberPropertyNotes } from './member-property-context.ts';
+
+const SEPARATE_MEMBER_NAME = 'Quinn Separate Own-Property Member';
+
+/** Arranges an accepted own-property member whose active community differs from Maya's. */
+export class BecomeSeparateOwnPropertyMember extends Task {
+ static inNewCommunity(): BecomeSeparateOwnPropertyMember {
+ return new BecomeSeparateOwnPropertyMember();
+ }
+
+ private constructor() {
+ super('becomes an accepted own-property member of a separate community');
+ }
+
+ async performAs(actor: Actor): Promise {
+ setActorToken(actor.name, userTokenFor(actors.OtherCommunityOwner.name));
+ await actor.attemptsTo(BecomePropertyManager.ofANewCommunity());
+ const communityId = await actor.answer(notes().get('activeCommunityId'));
+ const communityName = await actor.answer(notes().get('activeCommunityName'));
+ const managerMemberId = await actor.answer(notes().get('actingMemberId'));
+ if (!communityId || !managerMemberId) {
+ throw new Error('The separate manager arrangement did not return a community and member id');
+ }
+
+ const member = await ProvisionMemberPropertyFixtureAbility.as(actor).performAs(actor, {
+ communityId,
+ endUserId: END_USER_IDS.otherCommunityOwner,
+ memberName: SEPARATE_MEMBER_NAME,
+ firstName: actors.OtherCommunityOwner.givenName,
+ lastName: actors.OtherCommunityOwner.familyName,
+ accountStatus: 'ACCEPTED',
+ canEditOwnProperty: true,
+ canManageProperties: false,
+ });
+ setActorContext(actor.name, { memberId: member.memberId, communityId });
+ await initialiseMemberPropertyNotes(actor, { communityId, communityName, managerMemberId, actingMemberId: member.memberId });
+ }
+}
diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/establish-member-property-community.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/establish-member-property-community.ts
new file mode 100644
index 000000000..2ba3b8917
--- /dev/null
+++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/establish-member-property-community.ts
@@ -0,0 +1,50 @@
+import { actors, END_USER_IDS } from '@ocom-verification/verification-shared/test-data';
+import { type Actor, notes, Task } from '@serenity-js/core';
+import { setActorContext, setActorToken, userTokenFor } from '../../../shared/abilities/actor-auth.ts';
+import { ProvisionMemberPropertyFixture as ProvisionMemberPropertyFixtureAbility } from '../../../shared/abilities/provision-member-property-fixture.ts';
+import type { PropertyNotes } from '../../property/notes/property-notes.ts';
+import { BecomePropertyManager } from '../../property/tasks/become-property-manager.ts';
+import { initialiseMemberPropertyNotes } from './member-property-context.ts';
+
+const MAYA_MEMBER_NAME = 'Maya Own-Property Member';
+
+/**
+ * Creates a fresh community through the existing manager flow, then replaces
+ * the acting test principal with an accepted own-property member of it.
+ */
+export class EstablishMemberPropertyCommunity extends Task {
+ static forOwnPropertyMember(): EstablishMemberPropertyCommunity {
+ return new EstablishMemberPropertyCommunity();
+ }
+
+ private constructor() {
+ super('becomes an accepted own-property member of a member Property community');
+ }
+
+ async performAs(actor: Actor): Promise {
+ setActorToken(actor.name, userTokenFor(actors.CommunityOwner.name));
+ await actor.attemptsTo(BecomePropertyManager.ofANewCommunity());
+
+ const communityId = await actor.answer(notes().get('activeCommunityId'));
+ const communityName = await actor.answer(notes().get('activeCommunityName'));
+ const managerMemberId = await actor.answer(notes().get('actingMemberId'));
+ if (!communityId || !managerMemberId) {
+ throw new Error('The manager arrangement did not return a community and member id');
+ }
+
+ const ownMember = await ProvisionMemberPropertyFixtureAbility.as(actor).performAs(actor, {
+ communityId,
+ endUserId: END_USER_IDS.communityMember,
+ memberName: MAYA_MEMBER_NAME,
+ firstName: actors.CommunityMember.givenName,
+ lastName: actors.CommunityMember.familyName,
+ accountStatus: 'ACCEPTED',
+ canEditOwnProperty: true,
+ canManageProperties: false,
+ });
+
+ setActorToken(actor.name, userTokenFor(actors.CommunityMember.name));
+ setActorContext(actor.name, { memberId: ownMember.memberId, communityId });
+ await initialiseMemberPropertyNotes(actor, { communityId, communityName, managerMemberId, actingMemberId: ownMember.memberId });
+ }
+}
diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-context.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-context.ts
new file mode 100644
index 000000000..3891ca5aa
--- /dev/null
+++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-context.ts
@@ -0,0 +1,52 @@
+import { type Actor, notes } from '@serenity-js/core';
+import type { MemberPropertyNotes } from '../notes/member-property-notes.ts';
+
+export interface MemberPropertyCommunityContext {
+ communityId: string;
+ communityName: string;
+ managerMemberId: string;
+}
+
+interface MemberPropertyActorContext extends MemberPropertyCommunityContext {
+ actingMemberId: string;
+}
+
+export async function initialiseMemberPropertyNotes(actor: Actor, context: MemberPropertyActorContext): Promise {
+ await actor.attemptsTo(
+ notes().set('activeCommunityId', context.communityId),
+ notes().set('activeCommunityName', context.communityName),
+ notes().set('actingMemberId', context.actingMemberId),
+ notes().set('managerMemberId', context.managerMemberId),
+ notes().set('foreignMemberId', null),
+ notes().set('knownPropertyIds', {}),
+ notes().set('listedPropertyNames', []),
+ notes().set('directoryStatus', null),
+ notes().set('directoryError', null),
+ notes().set('lastOperationStatus', null),
+ notes().set('lastOperationError', null),
+ notes().set('lastReadStatus', null),
+ notes().set('lastReadError', null),
+ notes().set('lastReadPropertyId', null),
+ notes().set('baselinePropertyNames', []),
+ );
+}
+
+export async function memberPropertyCommunityOf(actor: Actor): Promise {
+ const communityId = await actor.answer(notes().get('activeCommunityId'));
+ const communityName = await actor.answer(notes().get('activeCommunityName'));
+ const managerMemberId = await actor.answer(notes().get('managerMemberId'));
+ if (!communityId || !managerMemberId) {
+ throw new Error('No member Property community is available. Did the accepted own-property member setup run?');
+ }
+ return { communityId, communityName, managerMemberId };
+}
+
+export async function memberPropertyIdKnownTo(actor: Actor, propertyName: string): Promise {
+ const propertyIds = await actor.answer(notes().get('knownPropertyIds'));
+ return propertyIds?.[propertyName];
+}
+
+export async function rememberMemberPropertyId(actor: Actor, propertyName: string, propertyId: string): Promise {
+ const current = (await actor.answer(notes().get('knownPropertyIds'))) ?? {};
+ await actor.attemptsTo(notes().set('knownPropertyIds', { ...current, [propertyName]: propertyId }));
+}
diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-operations.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-operations.ts
new file mode 100644
index 000000000..63a77057c
--- /dev/null
+++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-operations.ts
@@ -0,0 +1,200 @@
+import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql';
+import { type Actor, notes, Task } from '@serenity-js/core';
+import { PROPERTY_FULL_CREATE_MUTATION, PROPERTY_FULL_UPDATE_MUTATION, type PropertyFullCreateInput, type PropertyFullMutationResult, type PropertyFullUpdateInput } from '../../../shared/graphql/property-field-operations.ts';
+import { PROPERTIES_BY_COMMUNITY_ID_QUERY, PROPERTY_BY_ID_QUERY, PROPERTY_DELETE_MUTATION, type PropertyMutationResult, type PropertyResult } from '../../../shared/graphql/property-operations.ts';
+import type { MemberPropertyNotes } from '../notes/member-property-notes.ts';
+import { memberPropertyCommunityOf, rememberMemberPropertyId } from './member-property-context.ts';
+
+export interface MemberPropertyUpdateInput {
+ propertyName?: string;
+ propertyType?: string | null;
+ ownerId?: string | null;
+ listedInDirectory?: boolean;
+}
+
+const mutationError = (result: PropertyFullMutationResult | PropertyMutationResult | undefined, action: string): string => String(result?.status?.errorMessage ?? `Member Property ${action} was rejected`);
+
+async function recordMutation(actor: Actor, action: string, result: PropertyFullMutationResult | PropertyMutationResult | undefined, propertyName?: string): Promise {
+ if (result?.status?.success === true) {
+ const propertyId = result.property?.id;
+ if (propertyName && propertyId) {
+ await rememberMemberPropertyId(actor, propertyName, propertyId);
+ }
+ await actor.attemptsTo(notes().set('lastOperationStatus', 'SUCCESS'), notes().set('lastOperationError', null));
+ return;
+ }
+ await actor.attemptsTo(notes().set('lastOperationStatus', 'REJECTED'), notes().set('lastOperationError', mutationError(result, action)));
+}
+
+async function recordOperationError(actor: Actor, error: unknown): Promise {
+ const message = error instanceof Error ? error.message : String(error);
+ await actor.attemptsTo(notes().set('lastOperationStatus', 'ERROR'), notes().set('lastOperationError', message));
+}
+
+/** Sends a minimal member Property create request without an ownerId. */
+export class CreateMemberProperty extends Task {
+ static named(propertyName: string): CreateMemberProperty {
+ return new CreateMemberProperty(propertyName);
+ }
+
+ private constructor(private readonly propertyName: string) {
+ super(`creates the member Property "${propertyName}"`);
+ }
+
+ async performAs(actor: Actor): Promise {
+ try {
+ const response = await GraphQLClient.as(actor).execute(PROPERTY_FULL_CREATE_MUTATION, {
+ input: { propertyName: this.propertyName } satisfies PropertyFullCreateInput,
+ });
+ await recordMutation(actor, 'create', response.data.propertyCreate as PropertyFullMutationResult | undefined, this.propertyName);
+ } catch (error) {
+ await recordOperationError(actor, error);
+ }
+ }
+}
+
+/** Sends a deliberately raw create payload containing a forbidden ownerId. */
+export class AttemptMemberPropertyCreateWithOwner extends Task {
+ static named(propertyName: string, ownerId: string): AttemptMemberPropertyCreateWithOwner {
+ return new AttemptMemberPropertyCreateWithOwner(propertyName, ownerId);
+ }
+
+ private constructor(
+ private readonly propertyName: string,
+ private readonly ownerId: string,
+ ) {
+ super(`attempts to create the member Property "${propertyName}" with an owner id`);
+ }
+
+ async performAs(actor: Actor): Promise {
+ try {
+ const response = await GraphQLClient.as(actor).execute(PROPERTY_FULL_CREATE_MUTATION, {
+ input: { propertyName: this.propertyName, ownerId: this.ownerId } satisfies PropertyFullCreateInput,
+ });
+ await recordMutation(actor, 'create', response.data.propertyCreate as PropertyFullMutationResult | undefined, this.propertyName);
+ } catch (error) {
+ await recordOperationError(actor, error);
+ }
+ }
+}
+
+/** Sends a member Property update request and retains its observable result. */
+export class UpdateMemberProperty extends Task {
+ static with(propertyName: string, propertyId: string, input: MemberPropertyUpdateInput): UpdateMemberProperty {
+ return new UpdateMemberProperty(propertyName, propertyId, input);
+ }
+
+ private constructor(
+ private readonly propertyName: string,
+ private readonly propertyId: string,
+ private readonly input: MemberPropertyUpdateInput,
+ ) {
+ super(`updates the member Property "${propertyName}"`);
+ }
+
+ async performAs(actor: Actor): Promise {
+ try {
+ const response = await GraphQLClient.as(actor).execute(PROPERTY_FULL_UPDATE_MUTATION, {
+ input: { id: this.propertyId, ...this.input } satisfies PropertyFullUpdateInput,
+ });
+ await recordMutation(actor, 'update', response.data.propertyUpdate as PropertyFullMutationResult | undefined, this.propertyName);
+ } catch (error) {
+ await recordOperationError(actor, error);
+ }
+ }
+}
+
+/** Sends a member Property delete request and retains its observable result. */
+export class DeleteMemberProperty extends Task {
+ static identifiedAs(propertyName: string, propertyId: string): DeleteMemberProperty {
+ return new DeleteMemberProperty(propertyName, propertyId);
+ }
+
+ private constructor(
+ propertyName: string,
+ private readonly propertyId: string,
+ ) {
+ super(`deletes the member Property "${propertyName}"`);
+ }
+
+ async performAs(actor: Actor): Promise {
+ try {
+ const response = await GraphQLClient.as(actor).execute(PROPERTY_DELETE_MUTATION, { input: { id: this.propertyId } });
+ await recordMutation(actor, 'delete', response.data.propertyDelete as PropertyMutationResult | undefined);
+ } catch (error) {
+ await recordOperationError(actor, error);
+ }
+ }
+}
+
+/** Reads a member Property directory and records both success and failure state. */
+export class OpenMemberPropertyDirectory extends Task {
+ static currentCommunity(): OpenMemberPropertyDirectory {
+ return new OpenMemberPropertyDirectory(undefined);
+ }
+
+ static community(communityId: string): OpenMemberPropertyDirectory {
+ return new OpenMemberPropertyDirectory(communityId);
+ }
+
+ private constructor(private readonly requestedCommunityId: string | undefined) {
+ super('opens the member Property directory');
+ }
+
+ async performAs(actor: Actor): Promise {
+ const community = await memberPropertyCommunityOf(actor);
+ const communityId = this.requestedCommunityId ?? community.communityId;
+ try {
+ const response = await GraphQLClient.as(actor).execute(PROPERTIES_BY_COMMUNITY_ID_QUERY, { communityId });
+ const properties = response.data.propertiesByCommunityId as PropertyResult[];
+ await actor.attemptsTo(
+ notes().set(
+ 'listedPropertyNames',
+ properties.map((property) => property.propertyName),
+ ),
+ notes().set('directoryStatus', 'SUCCESS'),
+ notes().set('directoryError', null),
+ notes().set('lastOperationStatus', 'SUCCESS'),
+ notes().set('lastOperationError', null),
+ );
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ await actor.attemptsTo(
+ notes().set('listedPropertyNames', []),
+ notes().set('directoryStatus', 'ERROR'),
+ notes().set('directoryError', message),
+ notes().set('lastOperationStatus', 'ERROR'),
+ notes().set('lastOperationError', message),
+ );
+ }
+ }
+}
+
+/** Reads a detail by id and records a found, missing, or transport/error outcome. */
+export class ReadMemberProperty extends Task {
+ static identifiedAs(propertyName: string, propertyId: string): ReadMemberProperty {
+ return new ReadMemberProperty(propertyName, propertyId);
+ }
+
+ private constructor(
+ propertyName: string,
+ private readonly propertyId: string,
+ ) {
+ super(`reads the member Property "${propertyName}"`);
+ }
+
+ async performAs(actor: Actor): Promise {
+ try {
+ const response = await GraphQLClient.as(actor).execute(PROPERTY_BY_ID_QUERY, { id: this.propertyId });
+ const property = response.data.property as PropertyResult | null;
+ await actor.attemptsTo(
+ notes().set('lastReadStatus', property ? 'FOUND' : 'MISSING'),
+ notes().set('lastReadError', null),
+ notes().set('lastReadPropertyId', property?.id ?? null),
+ );
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ await actor.attemptsTo(notes().set('lastReadStatus', 'ERROR'), notes().set('lastReadError', message), notes().set('lastReadPropertyId', null));
+ }
+ }
+}
diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-personas.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-personas.ts
new file mode 100644
index 000000000..385912cd7
--- /dev/null
+++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-personas.ts
@@ -0,0 +1,69 @@
+import { actors, END_USER_IDS, type MemberPropertyFixture } from '@ocom-verification/verification-shared/test-data';
+import { type Actor, Task } from '@serenity-js/core';
+import { setActorContext, setActorToken, userTokenFor } from '../../../shared/abilities/actor-auth.ts';
+import { ProvisionMemberPropertyFixture as ProvisionMemberPropertyFixtureAbility } from '../../../shared/abilities/provision-member-property-fixture.ts';
+import { initialiseMemberPropertyNotes, type MemberPropertyCommunityContext } from './member-property-context.ts';
+
+export type MemberPropertyVisitor = 'no-permission' | 'no-role' | 'created' | 'rejected' | 'guest';
+
+const visitorFixture = (actorName: string, visitor: Exclude, communityId: string): MemberPropertyFixture => {
+ const accountStatus = visitor === 'created' ? 'CREATED' : visitor === 'rejected' ? 'REJECTED' : 'ACCEPTED';
+ return {
+ communityId,
+ endUserId: END_USER_IDS.otherCommunityOwner,
+ memberName: `${actorName} Member Property Visitor`,
+ firstName: actors.OtherCommunityOwner.givenName,
+ lastName: actors.OtherCommunityOwner.familyName,
+ accountStatus,
+ canEditOwnProperty: visitor !== 'no-permission' && visitor !== 'no-role',
+ canManageProperties: false,
+ hasRole: visitor !== 'no-role',
+ };
+};
+
+/** Makes an actor a deliberately ineligible principal in Maya's community. */
+export class BecomeMemberPropertyVisitor extends Task {
+ static inCommunity(visitor: MemberPropertyVisitor, community: MemberPropertyCommunityContext): BecomeMemberPropertyVisitor {
+ return new BecomeMemberPropertyVisitor(visitor, community);
+ }
+
+ private constructor(
+ private readonly visitor: MemberPropertyVisitor,
+ private readonly community: MemberPropertyCommunityContext,
+ ) {
+ super(`becomes a ${visitor} member Property visitor`);
+ }
+
+ async performAs(actor: Actor): Promise {
+ let memberId = '';
+ if (this.visitor === 'guest') {
+ setActorToken(actor.name, null);
+ setActorContext(actor.name, null);
+ } else {
+ const fixture = visitorFixture(actor.name, this.visitor, this.community.communityId);
+ const member = await ProvisionMemberPropertyFixtureAbility.as(actor).performAs(actor, fixture);
+ memberId = member.memberId;
+ setActorToken(actor.name, userTokenFor(actors.OtherCommunityOwner.name));
+ setActorContext(actor.name, { memberId, communityId: this.community.communityId });
+ }
+
+ await initialiseMemberPropertyNotes(actor, { ...this.community, actingMemberId: memberId });
+ }
+}
+
+/** Configures an actor to use the manager membership already provisioned for Maya's community. */
+export class BecomeMemberPropertyManager extends Task {
+ static inCommunity(community: MemberPropertyCommunityContext): BecomeMemberPropertyManager {
+ return new BecomeMemberPropertyManager(community);
+ }
+
+ private constructor(private readonly community: MemberPropertyCommunityContext) {
+ super('becomes the property manager of the member Property community');
+ }
+
+ async performAs(actor: Actor): Promise {
+ setActorToken(actor.name, userTokenFor(actors.CommunityOwner.name));
+ setActorContext(actor.name, { memberId: this.community.managerMemberId, communityId: this.community.communityId });
+ await initialiseMemberPropertyNotes(actor, { ...this.community, actingMemberId: this.community.managerMemberId });
+ }
+}
diff --git a/packages/ocom-verification/acceptance-api/src/servers/test-mongo-server.ts b/packages/ocom-verification/acceptance-api/src/servers/test-mongo-server.ts
index 25d8474fc..4f2ac38c3 100644
--- a/packages/ocom-verification/acceptance-api/src/servers/test-mongo-server.ts
+++ b/packages/ocom-verification/acceptance-api/src/servers/test-mongo-server.ts
@@ -22,9 +22,9 @@ export const testMongoServer = new MongoMemoryProcessTestServer({
});
function getDevScript(): 'dev' | 'dev:worktree' {
- return process.env['WORKTREE_NAME'] ? 'dev:worktree' : 'dev';
+ return process.env.WORKTREE_NAME ? 'dev:worktree' : 'dev';
}
-function mongoConnectionString(): string {
+export function mongoConnectionString(): string {
return `mongodb://127.0.0.1:${getMongoPort()}/${mongoDbName}?replicaSet=${mongoReplSetName}`;
}
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 f6bf7af78..9dd02e4bf 100644
--- a/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts
+++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts
@@ -4,6 +4,7 @@ export { createPropertyAbility } from './create-property.ts';
export { createStaffRoleAbility } from './create-staff-role.ts';
export { deletePropertyAbility } from './delete-property.ts';
export { createGraphQLClientAbility } from './graphql-client.ts';
+export { provisionMemberPropertyFixtureAbility } from './provision-member-property-fixture.ts';
export { provisionResidentMemberAbility } from './provision-resident-member.ts';
export { updatePropertyAbility } from './update-property.ts';
export { updateStaffRoleAbility } from './update-staff-role.ts';
diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/provision-member-property-fixture.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/provision-member-property-fixture.ts
new file mode 100644
index 000000000..7d3c72218
--- /dev/null
+++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/provision-member-property-fixture.ts
@@ -0,0 +1,28 @@
+import { type MemberPropertyFixture, type MemberPropertyFixtureIds, provisionMemberPropertyFixture } from '@ocom-verification/verification-shared/test-data';
+import { Ability, type Actor } from '@serenity-js/core';
+import { mongoConnectionString, mongoDbName } from '../../servers/test-mongo-server.ts';
+
+type ProvisionMemberPropertyFixtureHandler = (actor: Actor, fixture: MemberPropertyFixture) => Promise;
+
+/**
+ * Arrangement-only ability that provisions a member and role directly in the
+ * verification database. Product behavior is always exercised through GraphQL;
+ * direct writes only create principals unavailable through the public API.
+ */
+export class ProvisionMemberPropertyFixture extends Ability {
+ constructor(private readonly handler: ProvisionMemberPropertyFixtureHandler) {
+ super();
+ }
+
+ static using(handler: ProvisionMemberPropertyFixtureHandler): ProvisionMemberPropertyFixture {
+ return new ProvisionMemberPropertyFixture(handler);
+ }
+
+ async performAs(actor: Actor, fixture: MemberPropertyFixture): Promise {
+ return await this.handler(actor, fixture);
+ }
+}
+
+export function provisionMemberPropertyFixtureAbility(): ProvisionMemberPropertyFixture {
+ return ProvisionMemberPropertyFixture.using(async (_actor, fixture) => await provisionMemberPropertyFixture({ connectionString: mongoConnectionString(), dbName: mongoDbName }, fixture));
+}
diff --git a/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts
index e0a9a3828..bf05989db 100644
--- a/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts
+++ b/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts
@@ -5,6 +5,7 @@
import '../contexts/community/step-definitions/index.ts';
import '../contexts/authentication/step-definitions/index.ts';
+import '../contexts/member-property/step-definitions/index.ts';
import '../contexts/property/step-definitions/index.ts';
import '../contexts/staff/step-definitions/index.ts';
import '../contexts/staff-role/step-definitions/index.ts';
diff --git a/packages/ocom-verification/acceptance-api/src/world.ts b/packages/ocom-verification/acceptance-api/src/world.ts
index 2f189a986..6e96cd6ce 100644
--- a/packages/ocom-verification/acceptance-api/src/world.ts
+++ b/packages/ocom-verification/acceptance-api/src/world.ts
@@ -10,6 +10,7 @@ import { createStaffRoleAbility } from './shared/abilities/create-staff-role.ts'
import { deletePropertyAbility } from './shared/abilities/delete-property.ts';
import { createGraphQLClientAbility } from './shared/abilities/graphql-client.ts';
import { provisionDeactivatedPropertyManagerAbility } from './shared/abilities/provision-deactivated-property-manager.ts';
+import { provisionMemberPropertyFixtureAbility } from './shared/abilities/provision-member-property-fixture.ts';
import { provisionResidentMemberAbility } from './shared/abilities/provision-resident-member.ts';
import { updatePropertyAbility } from './shared/abilities/update-property.ts';
import { updateStaffRoleAbility } from './shared/abilities/update-staff-role.ts';
@@ -35,6 +36,7 @@ export const CellixApiWorld = registerManagedSerenityWorld({
() => deletePropertyAbility(),
() => provisionResidentMemberAbility(),
() => provisionDeactivatedPropertyManagerAbility(),
+ () => provisionMemberPropertyFixtureAbility(),
],
}),
});
diff --git a/packages/ocom-verification/acceptance-ui/package.json b/packages/ocom-verification/acceptance-ui/package.json
index 767f64a02..1c916855d 100644
--- a/packages/ocom-verification/acceptance-ui/package.json
+++ b/packages/ocom-verification/acceptance-ui/package.json
@@ -31,6 +31,7 @@
"@ocom-verification/verification-shared": "workspace:*",
"@ocom/ui-community-route-accounts": "workspace:*",
"@ocom/ui-community-route-admin": "workspace:*",
+ "@ocom/ui-community-route-member": "workspace:*",
"@ocom/ui-community-route-root": "workspace:*",
"@ocom/ui-staff-route-root": "workspace:*",
"@ocom/ui-staff-route-user-management": "workspace:*",
diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/member-property/abilities/mock-member-property-backend.ts b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/abilities/mock-member-property-backend.ts
new file mode 100644
index 000000000..7355e10e1
--- /dev/null
+++ b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/abilities/mock-member-property-backend.ts
@@ -0,0 +1,300 @@
+import type { MockedResponse } from '@apollo/client/testing';
+import { MemberPropertiesListDocument, MemberPropertiesRouteGuardMembersForCurrentEndUserDocument, MemberPropertyCreateDocument, MemberPropertyDetailDocument, MemberPropertyUpdateDocument } from '@ocom/ui-community-route-member';
+import { GraphQLError } from 'graphql';
+
+const MEMBER_PROPERTY_COMMUNITY_ID = '65e1a77bcf86cd79943900c1';
+const MEMBER_PROPERTY_OWN_EDITOR_ID = '65e1a77bcf86cd79943900a2';
+const MEMBER_PROPERTY_MANAGER_ID = '65e1a77bcf86cd79943900a1';
+const MEMBER_PROPERTY_FOREIGN_OWNER_ID = '65e1a77bcf86cd79943900a3';
+export const MEMBER_PROPERTY_FOREIGN_OWNER_NAME = 'Foreign Same-Community Owner';
+const MEMBER_PROPERTY_BASE_PATH = `/community/${MEMBER_PROPERTY_COMMUNITY_ID}/member/${MEMBER_PROPERTY_OWN_EDITOR_ID}/properties`;
+export const MEMBER_PROPERTY_ADMIN_BASE_PATH = `/community/${MEMBER_PROPERTY_COMMUNITY_ID}/admin/${MEMBER_PROPERTY_MANAGER_ID}/properties`;
+
+export type MemberPropertyUiVisitor = 'own-editor' | 'manager' | 'no-permission' | 'no-role' | 'nonaccepted' | 'guest' | 'mismatched-route';
+
+interface MemberPropertyMockRecord {
+ id: string;
+ propertyName: string;
+ propertyType: string | null;
+ listedInDirectory: boolean;
+ ownerId: string;
+ deleted: boolean;
+}
+
+interface MemberPropertyMutationInput {
+ id?: string;
+ propertyName?: string;
+ propertyType?: string | null;
+ ownerId?: string | null;
+ listedInDirectory?: boolean;
+}
+
+const CURRENT_END_USER_ID = '65e1a77bcf86cd79943900e1';
+
+let visitor: MemberPropertyUiVisitor = 'own-editor';
+let properties: MemberPropertyMockRecord[] = [];
+let nextId = 0;
+
+export function resetMemberPropertyUiState(): void {
+ visitor = 'own-editor';
+ properties = [];
+ nextId = 0;
+}
+
+export function useMemberPropertyUiVisitor(nextVisitor: MemberPropertyUiVisitor): void {
+ visitor = nextVisitor;
+}
+
+export function memberPropertyRouteFor(activeVisitor = visitor): string {
+ if (activeVisitor === 'manager') {
+ return `/community/${MEMBER_PROPERTY_COMMUNITY_ID}/member/${MEMBER_PROPERTY_MANAGER_ID}/properties`;
+ }
+ if (activeVisitor === 'mismatched-route') {
+ return `/community/${MEMBER_PROPERTY_COMMUNITY_ID}/member/65e1a77bcf86cd79943900ff/properties`;
+ }
+ return MEMBER_PROPERTY_BASE_PATH;
+}
+
+export function ensureForeignMemberProperty(propertyName: string): void {
+ if (properties.some((property) => property.propertyName === propertyName && !property.deleted)) {
+ return;
+ }
+ properties.push({
+ id: nextPropertyId(),
+ propertyName,
+ propertyType: null,
+ listedInDirectory: false,
+ ownerId: MEMBER_PROPERTY_FOREIGN_OWNER_ID,
+ deleted: false,
+ });
+}
+
+export function ensureOwnMemberProperty(propertyName: string): void {
+ if (properties.some((property) => property.propertyName === propertyName && !property.deleted)) {
+ return;
+ }
+ properties.push({
+ id: nextPropertyId(),
+ propertyName,
+ propertyType: null,
+ listedInDirectory: false,
+ ownerId: MEMBER_PROPERTY_OWN_EDITOR_ID,
+ deleted: false,
+ });
+}
+
+/** Read-only inspection of the mock persistence state used by member UI assertions. */
+export function memberPropertyMockRecord(propertyName: string): MemberPropertyMockRecord | undefined {
+ return properties.find((property) => property.propertyName === propertyName && !property.deleted);
+}
+
+const nextPropertyId = (): string => {
+ nextId += 1;
+ return `65e1b${nextId.toString(16).padStart(19, '0')}`;
+};
+
+const mayView = (): boolean => visitor === 'own-editor' || visitor === 'manager';
+const mayManage = (): boolean => visitor === 'manager';
+const mayEdit = (property: MemberPropertyMockRecord): boolean => mayManage() || (visitor === 'own-editor' && property.ownerId === MEMBER_PROPERTY_OWN_EDITOR_ID);
+
+const toListProperty = (property: MemberPropertyMockRecord) => ({
+ __typename: 'Property' as const,
+ id: property.id,
+ propertyName: property.propertyName,
+ propertyType: property.propertyType,
+ listingDetail: null,
+ location: null,
+ updatedAt: null,
+});
+
+const toDetailProperty = (property: MemberPropertyMockRecord) => ({
+ __typename: 'Property' as const,
+ id: property.id,
+ propertyName: property.propertyName,
+ propertyType: property.propertyType,
+ listedForSale: false,
+ listedForRent: false,
+ listedForLease: false,
+ listedInDirectory: property.listedInDirectory,
+ tags: [],
+ owner: { __typename: 'PropertyOwnerOption' as const, id: property.ownerId },
+ location: {
+ __typename: 'PropertyLocation' as const,
+ address: {
+ __typename: 'PropertyAddress' as const,
+ streetNumber: null,
+ streetName: null,
+ municipality: null,
+ countrySubdivision: null,
+ postalCode: null,
+ country: null,
+ },
+ },
+ listingDetail: {
+ __typename: 'PropertyListingDetail' as const,
+ price: null,
+ rentHigh: null,
+ rentLow: null,
+ lease: null,
+ maxGuests: null,
+ bedrooms: null,
+ bathrooms: null,
+ squareFeet: null,
+ yearBuilt: null,
+ lotSize: null,
+ description: null,
+ amenities: [],
+ bedroomDetails: [],
+ additionalAmenities: [],
+ images: [],
+ video: null,
+ floorPlan: null,
+ floorPlanImages: [],
+ listingAgent: null,
+ listingAgentPhone: null,
+ listingAgentEmail: null,
+ listingAgentWebsite: null,
+ listingAgentCompany: null,
+ listingAgentCompanyPhone: null,
+ listingAgentCompanyEmail: null,
+ listingAgentCompanyWebsite: null,
+ listingAgentCompanyAddress: null,
+ },
+ createdAt: null,
+ updatedAt: null,
+});
+
+const toCreateProperty = (property: MemberPropertyMockRecord) => ({
+ __typename: 'Property' as const,
+ id: property.id,
+ propertyName: property.propertyName,
+ owner: { __typename: 'PropertyOwnerOption' as const, id: property.ownerId },
+});
+
+const rejectedMutation = (field: 'propertyCreate' | 'propertyUpdate', errorMessage: string) => ({
+ data: {
+ [field]: {
+ __typename: 'PropertyMutationResult' as const,
+ status: { __typename: 'MutationStatus' as const, success: false, errorMessage },
+ property: null,
+ },
+ },
+});
+
+const guardMembership = () => {
+ if (visitor === 'guest') {
+ return [];
+ }
+ const routeMemberId = visitor === 'manager' ? MEMBER_PROPERTY_MANAGER_ID : MEMBER_PROPERTY_OWN_EDITOR_ID;
+ const accepted = visitor !== 'nonaccepted';
+ return [
+ {
+ __typename: 'Member' as const,
+ id: routeMemberId,
+ accounts: [{ __typename: 'MemberAccount' as const, statusCode: accepted ? 'ACCEPTED' : 'CREATED', user: { __typename: 'EndUser' as const, id: CURRENT_END_USER_ID } }],
+ role:
+ visitor === 'no-role'
+ ? null
+ : {
+ __typename: 'EndUserRole' as const,
+ permissions: {
+ __typename: 'EndUserRolePermissions' as const,
+ propertyPermissions: {
+ __typename: 'EndUserRolePropertyPermissions' as const,
+ canManageProperties: visitor === 'manager',
+ canEditOwnProperty: visitor === 'own-editor',
+ },
+ },
+ },
+ community: { __typename: 'Community' as const, id: MEMBER_PROPERTY_COMMUNITY_ID },
+ },
+ ];
+};
+
+/**
+ * Dynamic route mocks use the actual Phase 3 documents. No owner-options or
+ * delete mock is intentionally registered: such a request fails the test.
+ */
+export const buildMemberPropertyMocks = (): MockedResponse[] => [
+ {
+ request: { query: MemberPropertiesRouteGuardMembersForCurrentEndUserDocument },
+ maxUsageCount: Number.POSITIVE_INFINITY,
+ result: () => ({
+ data: {
+ currentEndUserAndCreateIfNotExists: { __typename: 'EndUser', id: CURRENT_END_USER_ID },
+ membersForCurrentEndUser: guardMembership(),
+ },
+ }),
+ },
+ {
+ request: { query: MemberPropertiesListDocument },
+ variableMatcher: () => true,
+ maxUsageCount: Number.POSITIVE_INFINITY,
+ result: () => {
+ if (!mayView()) return { errors: [new GraphQLError('Unauthorized')] };
+ return { data: { propertiesByCommunityId: properties.filter((property) => !property.deleted).map(toListProperty) } };
+ },
+ },
+ {
+ request: { query: MemberPropertyDetailDocument },
+ variableMatcher: () => true,
+ maxUsageCount: Number.POSITIVE_INFINITY,
+ result: (variables: { id?: string }) => {
+ if (!mayView()) return { data: { property: null } };
+ const property = properties.find((candidate) => candidate.id === variables.id && !candidate.deleted);
+ return { data: { property: property ? toDetailProperty(property) : null } };
+ },
+ },
+ {
+ request: { query: MemberPropertyCreateDocument },
+ variableMatcher: () => true,
+ maxUsageCount: Number.POSITIVE_INFINITY,
+ result: (variables: { input?: MemberPropertyMutationInput }) => {
+ const input = variables.input ?? {};
+ if (visitor !== 'own-editor') return rejectedMutation('propertyCreate', 'Unauthorized');
+ if (input.ownerId !== undefined) return rejectedMutation('propertyCreate', 'Owner must be assigned by the server');
+ if (!input.propertyName?.trim()) return rejectedMutation('propertyCreate', 'Property name is required');
+ const property: MemberPropertyMockRecord = {
+ id: nextPropertyId(),
+ propertyName: input.propertyName,
+ propertyType: null,
+ listedInDirectory: false,
+ ownerId: MEMBER_PROPERTY_OWN_EDITOR_ID,
+ deleted: false,
+ };
+ properties.push(property);
+ return {
+ data: {
+ propertyCreate: {
+ __typename: 'PropertyMutationResult' as const,
+ status: { __typename: 'MutationStatus' as const, success: true, errorMessage: null },
+ property: toCreateProperty(property),
+ },
+ },
+ };
+ },
+ },
+ {
+ request: { query: MemberPropertyUpdateDocument },
+ variableMatcher: () => true,
+ maxUsageCount: Number.POSITIVE_INFINITY,
+ result: (variables: { input?: MemberPropertyMutationInput }) => {
+ const input = variables.input ?? {};
+ const property = properties.find((candidate) => candidate.id === input.id && !candidate.deleted);
+ if (!property || !mayEdit(property)) return rejectedMutation('propertyUpdate', 'Property not found');
+ if (!mayManage() && (input.propertyName !== undefined || input.propertyType !== undefined || input.ownerId !== undefined)) {
+ return rejectedMutation('propertyUpdate', 'Property not found');
+ }
+ if (input.listedInDirectory !== undefined) property.listedInDirectory = input.listedInDirectory;
+ return {
+ data: {
+ propertyUpdate: {
+ __typename: 'PropertyMutationResult' as const,
+ status: { __typename: 'MutationStatus' as const, success: true, errorMessage: null },
+ property: toDetailProperty(property),
+ },
+ },
+ };
+ },
+ },
+];
diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/member-property/notes/member-property-ui-notes.ts b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/notes/member-property-ui-notes.ts
new file mode 100644
index 000000000..385a162f3
--- /dev/null
+++ b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/notes/member-property-ui-notes.ts
@@ -0,0 +1,7 @@
+import type { MemberPropertyUiVisitor } from '../abilities/mock-member-property-backend.ts';
+
+/** Scenario-local member route state for DOM acceptance tests. */
+export interface MemberPropertyUiNotes {
+ visitor: MemberPropertyUiVisitor;
+ memberPropertyRoute: string;
+}
diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/member-property/questions/member-property-screen.ts b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/questions/member-property-screen.ts
new file mode 100644
index 000000000..25981ff43
--- /dev/null
+++ b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/questions/member-property-screen.ts
@@ -0,0 +1,42 @@
+import { type Actor, Question } from '@serenity-js/core';
+import { memberListPageFor, memberPropertyCurrentRouteFor, memberReadOnlyDetailPageFor, OpenMemberPropertyDirectory } from '../tasks/member-property-screen.tsx';
+
+export const MemberPropertyDirectoryRendered = () =>
+ Question.about('whether the member Property directory rendered', async (actor) => {
+ const page = memberListPageFor(actor);
+ return (
+ (await page.adapter.locator('.ant-table').isVisible()) ||
+ (await memberReadOnlyDetailPageFor(actor)
+ .adapter.getByText(/^Property Details$/)
+ .isVisible()
+ .catch(() => false))
+ );
+ });
+
+export const MemberPropertyDirectoryNames = () =>
+ Question.about('the visible member Property directory names', async (actor) => {
+ await (actor as Actor).attemptsTo(OpenMemberPropertyDirectory());
+ return await memberListPageFor(actor).listedPropertyNames();
+ });
+
+export const MemberPropertyDetailIsReadOnly = () =>
+ Question.about('whether the member Property detail is read-only', async (actor) => {
+ const detail = memberReadOnlyDetailPageFor(actor);
+ return (await detail.heading.isVisible()) && !(await detail.hasEditableControls());
+ });
+
+export const ForeignOwnerIdentityIsVisible = (ownerName: string) =>
+ Question.about(`whether the foreign owner identity "${ownerName}" is visible`, async (actor) => {
+ return await memberReadOnlyDetailPageFor(actor).displaysOwnerIdentity(ownerName);
+ });
+
+export const MemberPropertyRouteDenied = () =>
+ Question.about('whether the member Property route shows an access-denied result', async (actor) => {
+ const currentRoute = (await memberPropertyCurrentRouteFor(actor).textContent()) ?? '';
+ if (!currentRoute.includes('/member/')) return true;
+ return await memberReadOnlyDetailPageFor(actor)
+ .adapter.getByText(/access denied|forbidden|not authorized/i)
+ .isVisible();
+ });
+
+export const MemberPropertyCurrentRoute = () => Question.about('the current member Property route', async (actor) => (await memberPropertyCurrentRouteFor(actor).textContent()) ?? '');
diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/member-property/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/step-definitions/index.ts
new file mode 100644
index 000000000..6da6a6691
--- /dev/null
+++ b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/step-definitions/index.ts
@@ -0,0 +1 @@
+import './member-property.steps.ts';
diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/member-property/step-definitions/member-property.steps.ts b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/step-definitions/member-property.steps.ts
new file mode 100644
index 000000000..14c52b69f
--- /dev/null
+++ b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/step-definitions/member-property.steps.ts
@@ -0,0 +1,142 @@
+import { Given, Then, When } from '@cucumber/cucumber';
+import { actorCalled, actorInTheSpotlight } from '@serenity-js/core';
+import {
+ ensureForeignMemberProperty,
+ ensureOwnMemberProperty,
+ MEMBER_PROPERTY_ADMIN_BASE_PATH,
+ MEMBER_PROPERTY_FOREIGN_OWNER_NAME,
+ type MemberPropertyUiVisitor,
+ memberPropertyMockRecord,
+ resetMemberPropertyUiState,
+ useMemberPropertyUiVisitor,
+} from '../abilities/mock-member-property-backend.ts';
+import {
+ ForeignOwnerIdentityIsVisible,
+ MemberPropertyCurrentRoute,
+ MemberPropertyDetailIsReadOnly,
+ MemberPropertyDirectoryNames,
+ MemberPropertyDirectoryRendered,
+ MemberPropertyRouteDenied,
+} from '../questions/member-property-screen.ts';
+import { CreateMemberPropertyViaForm, OpenMemberPropertyDetail, OpenMemberPropertyDirectory, PrepareMemberPropertyVisitor, UpdateMemberPropertyListingFlagViaForm } from '../tasks/member-property-screen.tsx';
+
+let lastActorName = 'Maya';
+
+const routeVisitor = (visitor: string): MemberPropertyUiVisitor => {
+ const visitors: Record = {
+ 'no-permission': 'no-permission',
+ 'no-role': 'no-role',
+ nonaccepted: 'nonaccepted',
+ guest: 'guest',
+ 'mismatched-route': 'mismatched-route',
+ };
+ const resolved = visitors[visitor];
+ if (!resolved) {
+ throw new Error(`Unsupported member Property route visitor "${visitor}"`);
+ }
+ return resolved;
+};
+
+Given('{word} is an accepted own-property member in a member Property community', async (actorName: string) => {
+ lastActorName = actorName;
+ resetMemberPropertyUiState();
+ useMemberPropertyUiVisitor('own-editor');
+ await actorCalled(actorName).attemptsTo(PrepareMemberPropertyVisitor('own-editor'));
+});
+
+Given('a foreign same-community member owns the member Property {string}', (propertyName: string) => {
+ ensureForeignMemberProperty(propertyName);
+});
+
+Given('{word} owns the member Property {string}', (_actorName: string, propertyName: string) => {
+ ensureOwnMemberProperty(propertyName);
+});
+
+Given("{word} is a {string} member Property route visitor in Maya's community", async (actorName: string, visitor: string) => {
+ lastActorName = actorName;
+ const routePersona = routeVisitor(visitor);
+ useMemberPropertyUiVisitor(routePersona);
+ await actorCalled(actorName).attemptsTo(PrepareMemberPropertyVisitor(routePersona));
+});
+
+Given("{word} is the property manager of Maya's member Property community", async (actorName: string) => {
+ lastActorName = actorName;
+ useMemberPropertyUiVisitor('manager');
+ await actorCalled(actorName).attemptsTo(PrepareMemberPropertyVisitor('manager'));
+});
+
+When('{word} opens the member Property directory', async (actorName: string) => {
+ lastActorName = actorName;
+ await actorCalled(actorName).attemptsTo(OpenMemberPropertyDirectory());
+});
+
+When('{word} opens their member Property directory', async (actorName: string) => {
+ lastActorName = actorName;
+ await actorCalled(actorName).attemptsTo(OpenMemberPropertyDirectory());
+});
+
+When('{word} opens the member Property details for {string}', async (actorName: string, propertyName: string) => {
+ lastActorName = actorName;
+ await actorCalled(actorName).attemptsTo(OpenMemberPropertyDetail(propertyName));
+});
+
+When('{word} creates a member Property named {string}', async (actorName: string, propertyName: string) => {
+ lastActorName = actorName;
+ await actorCalled(actorName).attemptsTo(CreateMemberPropertyViaForm(propertyName));
+});
+
+When('{word} sets the member Property {string} listing flag {string} to {string}', async (actorName: string, propertyName: string, flag: string, value: string) => {
+ if (flag !== 'listedInDirectory' || (value !== 'true' && value !== 'false')) {
+ throw new Error(`Unsupported member Property UI listing update ${flag}=${value}`);
+ }
+ lastActorName = actorName;
+ await actorCalled(actorName).attemptsTo(UpdateMemberPropertyListingFlagViaForm(propertyName, value === 'true'));
+});
+
+Then('the member Property operation succeeds', async () => {
+ if (!(await actorCalled(lastActorName).answer(MemberPropertyDirectoryRendered()))) {
+ throw new Error('Expected the member Property operation to succeed, but the member Property route did not render');
+ }
+});
+
+Then('the member Property directory includes {string}', async (propertyName: string) => {
+ const names = await actorCalled(lastActorName).answer(MemberPropertyDirectoryNames());
+ if (!names.includes(propertyName)) {
+ throw new Error(`Expected the member Property directory to include "${propertyName}", but it listed: ${names.join(', ') || 'none'}`);
+ }
+});
+
+Then('the member Property detail is read-only', async () => {
+ if (!(await actorInTheSpotlight().answer(MemberPropertyDetailIsReadOnly()))) {
+ throw new Error('Expected the foreign member Property detail to render as read-only');
+ }
+});
+
+Then('the member Property detail does not display the foreign owner identity', async () => {
+ if (await actorInTheSpotlight().answer(ForeignOwnerIdentityIsVisible(MEMBER_PROPERTY_FOREIGN_OWNER_NAME))) {
+ throw new Error(`Expected the foreign owner identity "${MEMBER_PROPERTY_FOREIGN_OWNER_NAME}" to be hidden from the member detail`);
+ }
+});
+
+Then('the member Property {string} has listing flag {string} set to {string}', (propertyName: string, flag: string, expectedValue: string) => {
+ if (flag !== 'listedInDirectory') {
+ throw new Error(`Unsupported member Property UI flag "${flag}"`);
+ }
+ const property = memberPropertyMockRecord(propertyName);
+ if (!property || property.listedInDirectory !== (expectedValue === 'true')) {
+ throw new Error(`Expected "${propertyName}" to persist ${flag}=${expectedValue}`);
+ }
+});
+
+Then('{word} is denied the member Property route', async (actorName: string) => {
+ if (!(await actorCalled(actorName).answer(MemberPropertyRouteDenied()))) {
+ throw new Error(`Expected ${actorName} to see the member Property access-denied result`);
+ }
+});
+
+Then('{word} is redirected to the admin Property directory', async (actorName: string) => {
+ const currentRoute = await actorCalled(actorName).answer(MemberPropertyCurrentRoute());
+ if (currentRoute !== MEMBER_PROPERTY_ADMIN_BASE_PATH) {
+ throw new Error(`Expected ${actorName} to be redirected to "${MEMBER_PROPERTY_ADMIN_BASE_PATH}", but the member route remained at "${currentRoute}"`);
+ }
+});
diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/member-property/tasks/member-property-screen.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/tasks/member-property-screen.tsx
new file mode 100644
index 000000000..c68b6559f
--- /dev/null
+++ b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/tasks/member-property-screen.tsx
@@ -0,0 +1,136 @@
+import { Render, RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom';
+import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom';
+import { TaskStep } from '@cellix/serenity-framework/serenity';
+import { Member } from '@ocom/ui-community-route-member';
+import { MemberPropertiesListPage, MemberPropertyReadOnlyDetailPage, PropertyFormPage } from '@ocom-verification/verification-shared/pages';
+import { type Actor, notes, Task, type UsesAbilities } from '@serenity-js/core';
+import React from 'react';
+import { Route, Routes, useLocation } from 'react-router-dom';
+import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts';
+import { buildMemberPropertyMocks, type MemberPropertyUiVisitor, memberPropertyRouteFor } from '../abilities/mock-member-property-backend.ts';
+import type { MemberPropertyUiNotes } from '../notes/member-property-ui-notes.ts';
+
+const ROUTE_PATH_SELECTOR = '.member-property-current-route';
+
+const CurrentRoute: React.FC = () => {
+ const location = useLocation();
+ return ;
+};
+
+export const memberListPageFor = (actor: UsesAbilities): MemberPropertiesListPage => new MemberPropertiesListPage(new DomPageAdapter(RenderInDom.as(actor).container));
+
+export const memberReadOnlyDetailPageFor = (actor: UsesAbilities): MemberPropertyReadOnlyDetailPage => new MemberPropertyReadOnlyDetailPage(new DomPageAdapter(RenderInDom.as(actor).container));
+
+const memberPropertyFormPageFor = (actor: UsesAbilities): PropertyFormPage => new PropertyFormPage(new DomPageAdapter(RenderInDom.as(actor).container));
+
+export const memberPropertyCurrentRouteFor = (actor: UsesAbilities) => new DomPageAdapter(RenderInDom.as(actor).container).locator(ROUTE_PATH_SELECTOR);
+
+const pause = (): Promise => new Promise((resolve) => setTimeout(resolve, 10));
+
+type RouteContentPredicate = (adapter: DomPageAdapter) => Promise;
+
+const isVisible = async (predicate: () => Promise): Promise => predicate().catch(() => false);
+
+const directoryIsVisible: RouteContentPredicate = (adapter) => isVisible(() => adapter.getByText(/Community Properties \(/).isVisible());
+const deniedResultIsVisible: RouteContentPredicate = (adapter) => isVisible(() => adapter.locator('.member-property-route-forbidden').isVisible());
+const propertyFormIsVisible: RouteContentPredicate = (adapter) => isVisible(() => adapter.getByLabel('Property Name').isVisible());
+const propertyDetailIsVisible: RouteContentPredicate = async (adapter) =>
+ (await isVisible(() => adapter.locator('.property-read-only-detail').isVisible())) || (await isVisible(() => adapter.locator('form').isVisible())) || (await isVisible(() => adapter.getByText('Property Not Found').isVisible()));
+
+const waitForMemberRoute = async (actor: Actor, matchesRoute: (route: string) => boolean, hasExpectedContent: RouteContentPredicate): Promise => {
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ const route = (await memberPropertyCurrentRouteFor(actor).textContent()) ?? '';
+ if (matchesRoute(route) && (await hasExpectedContent(new DomPageAdapter(RenderInDom.as(actor).container)))) {
+ return;
+ }
+ await pause();
+ }
+ throw new Error('The member Property route did not settle after navigation.');
+};
+
+const waitForRouteResolution = async (actor: Actor): Promise => {
+ await waitForMemberRoute(
+ actor,
+ (route) => !route.includes('/member/') || route.endsWith('/properties'),
+ async (adapter) => (await directoryIsVisible(adapter)) || (await deniedResultIsVisible(adapter)) || !((await memberPropertyCurrentRouteFor(actor).textContent()) ?? '').includes('/member/'),
+ );
+};
+
+export const PrepareMemberPropertyVisitor = (visitor: MemberPropertyUiVisitor): Task =>
+ Task.where(
+ `#actor becomes a ${visitor} member Property route visitor`,
+ new TaskStep('#actor records the member Property route visitor', async (actor) => {
+ const route = memberPropertyRouteFor(visitor);
+ await actor.attemptsTo(notes().set('visitor', visitor), notes().set('memberPropertyRoute', route));
+ }),
+ );
+
+const RenderMemberPropertyRoute = (): Task =>
+ Task.where(
+ '#actor renders the member Property route',
+ new TaskStep('#actor mounts the member Property route boundary', async (actor) => {
+ const route = await actor.answer(notes().get('memberPropertyRoute'));
+ await actor.attemptsTo(
+ Render.component(
+ React.createElement(
+ React.Fragment,
+ null,
+ React.createElement(
+ Routes,
+ null,
+ React.createElement(Route, {
+ path: '/community/:communityId/member/:memberId/*',
+ element: React.createElement(Member),
+ }),
+ React.createElement(Route, {
+ path: '*',
+ element: React.createElement('div', { role: 'status' }, 'Member Property route redirected'),
+ }),
+ ),
+ React.createElement(CurrentRoute),
+ ),
+ { wrapper: wrapOcomComponent({ mocks: buildMemberPropertyMocks(), initialEntries: [route] }) },
+ ),
+ );
+ await waitForRouteResolution(actor);
+ }),
+ );
+
+export const OpenMemberPropertyDirectory = (): Task => Task.where('#actor opens the member Property directory', RenderMemberPropertyRoute());
+
+export const OpenMemberPropertyDetail = (propertyName: string): Task =>
+ Task.where(
+ `#actor opens the member Property details for "${propertyName}"`,
+ OpenMemberPropertyDirectory(),
+ new TaskStep('#actor selects the member Property details action', async (actor) => {
+ await memberListPageFor(actor).clickViewForProperty(propertyName);
+ await waitForMemberRoute(actor, (route) => /\/properties\/[^/]+$/.test(route), propertyDetailIsVisible);
+ }),
+ );
+
+export const CreateMemberPropertyViaForm = (propertyName: string): Task =>
+ Task.where(
+ `#actor creates the member Property "${propertyName}"`,
+ OpenMemberPropertyDirectory(),
+ new TaskStep('#actor submits the member Property create form', async (actor) => {
+ const list = memberListPageFor(actor);
+ await list.clickAddProperty();
+ await waitForMemberRoute(actor, (route) => route.endsWith('/properties/create'), propertyFormIsVisible);
+ const form = memberPropertyFormPageFor(actor);
+ await form.fillPropertyName(propertyName);
+ await form.clickCreate();
+ await waitForMemberRoute(actor, (route) => route.endsWith('/properties'), directoryIsVisible);
+ }),
+ );
+
+export const UpdateMemberPropertyListingFlagViaForm = (propertyName: string, value: boolean): Task =>
+ Task.where(
+ `#actor updates ${propertyName} in the member Property form`,
+ OpenMemberPropertyDetail(propertyName),
+ new TaskStep('#actor saves the member-editable listing flag', async (actor) => {
+ const form = memberPropertyFormPageFor(actor);
+ await form.setListingFlag('listedInDirectory', value);
+ await form.clickSave();
+ await pause();
+ }),
+ );
diff --git a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts
index 2b91f89a9..873414154 100644
--- a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts
+++ b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts
@@ -4,6 +4,7 @@
*/
import '../contexts/community/step-definitions/index.ts';
+import '../contexts/member-property/step-definitions/index.ts';
import '../contexts/property/step-definitions/index.ts';
import '../contexts/staff/step-definitions/index.ts';
import '../contexts/staff-role/step-definitions/index.ts';
diff --git a/packages/ocom-verification/acceptance-ui/tsconfig.json b/packages/ocom-verification/acceptance-ui/tsconfig.json
index b4b255926..d8dbe8b39 100644
--- a/packages/ocom-verification/acceptance-ui/tsconfig.json
+++ b/packages/ocom-verification/acceptance-ui/tsconfig.json
@@ -23,6 +23,8 @@
"../../cellix/serenity-framework/src/dom/css-module-types.d.ts",
"../../cellix/serenity-framework/src/dom/css-types.d.ts",
"../../ocom/ui-community-route-admin/src/**/*.tsx",
+ "../../ocom/ui-community-route-member/src/**/*.tsx",
+ "../../ocom/ui-community-shared/src/**/*.tsx",
"../../ocom/ui-community-route-root/src/**/*.tsx",
"../../ocom/ui-staff-route-root/src/**/*.tsx",
"../../ocom/ui-staff-route-user-management/src/**/*.tsx",
diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/abilities/member-property-portal-page.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/abilities/member-property-portal-page.ts
new file mode 100644
index 000000000..5dbce502b
--- /dev/null
+++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/abilities/member-property-portal-page.ts
@@ -0,0 +1,96 @@
+import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright';
+import { MemberPropertiesListPage, MemberPropertyReadOnlyDetailPage, PropertyFormPage } from '@ocom-verification/verification-shared/pages';
+import { actors } from '@ocom-verification/verification-shared/test-data';
+import { type AnswersQuestions, notes, type UsesAbilities } from '@serenity-js/core';
+import type { BrowserContext, Page } from 'playwright';
+import { infrastructure } from '../../../infrastructure.ts';
+import { performOAuth2Login } from '../../../shared/abilities/oauth2-login.ts';
+import type { MemberPropertyBrowserVisitor, MemberPropertyE2ENotes } from '../notes/member-property-notes.ts';
+
+interface PortalCredentials {
+ email: string;
+ password: string;
+}
+
+interface MemberPropertyPortalSession {
+ context: BrowserContext;
+ page: Page;
+ authenticated: boolean;
+}
+
+const credentialsFor = (visitor: MemberPropertyBrowserVisitor): PortalCredentials | undefined => {
+ switch (visitor) {
+ case 'own-editor':
+ case 'mismatched-route':
+ return { email: actors.CommunityMember.email, password: 'password' };
+ case 'manager':
+ return { email: actors.CommunityOwner.email, password: 'password' };
+ case 'no-permission':
+ case 'no-role':
+ case 'nonaccepted':
+ return { email: actors.OtherCommunityOwner.email, password: 'password' };
+ case 'guest':
+ return undefined;
+ }
+};
+
+const sessions = new Map();
+
+/** Returns an isolated community-portal page for the requested member fixture identity. */
+async function memberPropertyPortalPageFor(visitor: MemberPropertyBrowserVisitor): Promise {
+ let session = sessions.get(visitor);
+ if (!session) {
+ const context = await infrastructure.newPortalContext('community');
+ session = { context, page: await context.newPage(), authenticated: false };
+ sessions.set(visitor, session);
+ }
+ const credentials = credentialsFor(visitor);
+ if (credentials && !session.authenticated) {
+ await performOAuth2Login(session.page, credentials, '/community/accounts');
+ session.authenticated = true;
+ }
+ return session.page;
+}
+
+/** Closes all isolated member Property browser contexts created by the suite. */
+export async function closeMemberPropertyPortalSessions(): Promise {
+ for (const session of sessions.values()) {
+ await session.context.close().catch(() => undefined);
+ }
+ sessions.clear();
+}
+
+export const memberPropertyPortalPageOf = async (actor: AnswersQuestions): Promise => {
+ const visitor = await actor.answer(notes().get('visitor'));
+ return await memberPropertyPortalPageFor(visitor);
+};
+
+export const memberPropertiesListOn = (page: Page): MemberPropertiesListPage => new MemberPropertiesListPage(new PlaywrightPageAdapter(page));
+
+export const memberPropertyReadOnlyDetailOn = (page: Page): MemberPropertyReadOnlyDetailPage => new MemberPropertyReadOnlyDetailPage(new PlaywrightPageAdapter(page));
+
+export const memberPropertyFormOn = (page: Page): PropertyFormPage => new PropertyFormPage(new PlaywrightPageAdapter(page));
+
+export async function memberBasePathOf(actor: AnswersQuestions & UsesAbilities): Promise {
+ const basePath = await actor.answer(notes().get('memberBasePath'));
+ if (!basePath) {
+ throw new Error('No member Property base path was recorded. Did the member Property setup run?');
+ }
+ return basePath;
+}
+
+export async function adminBasePathOfMemberProperty(actor: AnswersQuestions & UsesAbilities): Promise {
+ const basePath = await actor.answer(notes().get('adminBasePath'));
+ if (!basePath) {
+ throw new Error('No admin Property base path was recorded. Did the member Property setup run?');
+ }
+ return basePath;
+}
+
+export const memberPropertyRouteDiagnostic = async (page: Page): Promise => {
+ const body = await page
+ .locator('body')
+ .textContent()
+ .catch(() => null);
+ return `url=${page.url()}; body=${body?.replace(/\s+/g, ' ').trim().slice(0, 400) ?? ''}`;
+};
diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/open-member-property-detail.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/open-member-property-detail.ts
new file mode 100644
index 000000000..e38441f48
--- /dev/null
+++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/open-member-property-detail.ts
@@ -0,0 +1,20 @@
+import { type Actor, Interaction, the } from '@serenity-js/core';
+import { memberPropertiesListOn, memberPropertyPortalPageOf, memberPropertyRouteDiagnostic } from '../abilities/member-property-portal-page.ts';
+import { OpenMemberPropertyDirectory } from './open-member-property-directory.ts';
+
+/** Opens a member Property detail from the directory once the member route is available. */
+export const OpenMemberPropertyDetail = (propertyName: string) =>
+ Interaction.where(the`#actor opens the member Property details for "${propertyName}"`, async (serenityActor) => {
+ const actor = serenityActor as unknown as Actor;
+ await actor.attemptsTo(OpenMemberPropertyDirectory());
+ const page = await memberPropertyPortalPageOf(actor);
+ const list = memberPropertiesListOn(page);
+ const headingVisible = await list.heading
+ .waitFor({ state: 'visible', timeout: 5_000 })
+ .then(() => true)
+ .catch(() => false);
+ if (!headingVisible) {
+ throw new Error(`The member Property directory did not render before opening "${propertyName}": ${await memberPropertyRouteDiagnostic(page)}`);
+ }
+ await list.clickViewForProperty(propertyName);
+ });
diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/open-member-property-directory.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/open-member-property-directory.ts
new file mode 100644
index 000000000..210a34d2a
--- /dev/null
+++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/open-member-property-directory.ts
@@ -0,0 +1,14 @@
+import { type Actor, Interaction, the } from '@serenity-js/core';
+import { memberBasePathOf, memberPropertyPortalPageOf } from '../abilities/member-property-portal-page.ts';
+
+/**
+ * Navigates to the actual member Property URL. It deliberately does not wait
+ * for a feature selector so Phase 1 reports the missing route in assertions,
+ * rather than mistaking it for browser infrastructure failure.
+ */
+export const OpenMemberPropertyDirectory = () =>
+ Interaction.where(the`#actor opens the member Property directory`, async (serenityActor) => {
+ const actor = serenityActor as unknown as Actor;
+ const page = await memberPropertyPortalPageOf(actor);
+ await page.goto(`${await memberBasePathOf(actor)}/properties`, { waitUntil: 'networkidle' });
+ });
diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/record-member-property-id.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/record-member-property-id.ts
new file mode 100644
index 000000000..7638422c9
--- /dev/null
+++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/record-member-property-id.ts
@@ -0,0 +1,28 @@
+import { findMemberPropertyFixtureRecord } from '@ocom-verification/verification-shared/test-data';
+import { type Actor, Interaction, notes, the } from '@serenity-js/core';
+import { mongoConnectionString, mongoDbName } from '../../../servers/test-mongo-server.ts';
+import { waitUntil } from '../../property/abilities/admin-portal-page.ts';
+import type { MemberPropertyE2ENotes } from '../notes/member-property-notes.ts';
+
+/** Records a fixture property's id in scenario-local member notes, never in the admin context's shared id map. */
+export const RecordMemberPropertyId = (propertyName: string) =>
+ Interaction.where(the`#actor records the member Property id for "${propertyName}"`, async (serenityActor) => {
+ const actor = serenityActor as unknown as Actor;
+ const communityId = await actor.answer(notes().get('communityId'));
+ let property = await findMemberPropertyFixtureRecord({ connectionString: mongoConnectionString(), dbName: mongoDbName }, communityId, propertyName);
+ if (!property) {
+ await waitUntil(
+ async () => {
+ property = await findMemberPropertyFixtureRecord({ connectionString: mongoConnectionString(), dbName: mongoDbName }, communityId, propertyName);
+ return property !== undefined;
+ },
+ `Could not record the member Property id for "${propertyName}" because it was not persisted`,
+ 5_000,
+ );
+ }
+ if (!property) {
+ throw new Error(`Could not record the member Property id for "${propertyName}" because it was not persisted`);
+ }
+ const ids = await actor.answer(notes().get('memberPropertyIds'));
+ await actor.attemptsTo(notes().set('memberPropertyIds', { ...ids, [propertyName]: property.id }));
+ });
diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/notes/member-property-notes.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/notes/member-property-notes.ts
new file mode 100644
index 000000000..689f08bd3
--- /dev/null
+++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/notes/member-property-notes.ts
@@ -0,0 +1,15 @@
+export type MemberPropertyBrowserVisitor = 'own-editor' | 'manager' | 'no-permission' | 'no-role' | 'nonaccepted' | 'guest' | 'mismatched-route';
+
+/** Scenario-local state used by the member Property browser flows. */
+export interface MemberPropertyE2ENotes {
+ visitor: MemberPropertyBrowserVisitor;
+ communityId: string;
+ managerMemberId: string;
+ ownMemberId: string;
+ routeMemberId: string;
+ memberBasePath: string;
+ adminBasePath: string;
+ memberPropertyIds: Record;
+ lastOperationStatus?: 'SUCCESS' | 'FAILURE';
+ lastOperationError?: string;
+}
diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/questions/member-property-screen.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/questions/member-property-screen.ts
new file mode 100644
index 000000000..2db14b6cc
--- /dev/null
+++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/questions/member-property-screen.ts
@@ -0,0 +1,131 @@
+import { findMemberPropertyFixtureRecord } from '@ocom-verification/verification-shared/test-data';
+import { type Actor, notes, Question } from '@serenity-js/core';
+import { mongoConnectionString, mongoDbName } from '../../../servers/test-mongo-server.ts';
+import { adminBasePathOfMemberProperty, memberBasePathOf, memberPropertiesListOn, memberPropertyPortalPageOf, memberPropertyReadOnlyDetailOn, memberPropertyRouteDiagnostic } from '../abilities/member-property-portal-page.ts';
+import type { MemberPropertyE2ENotes } from '../notes/member-property-notes.ts';
+
+async function memberDirectoryIsRendered(actor: Actor): Promise {
+ const page = await memberPropertyPortalPageOf(actor);
+ return await memberPropertiesListOn(page).heading.isVisible();
+}
+
+/** Waits for a member directory row to appear, retaining route diagnostics on failure. */
+export const MemberPropertyDirectoryIncludes = (propertyName: string) =>
+ Question.about(`whether the member Property directory includes "${propertyName}"`, async (serenityActor) => {
+ const actor = serenityActor as unknown as Actor;
+ const page = await memberPropertyPortalPageOf(actor);
+ const list = memberPropertiesListOn(page);
+ const rendered = await list.heading
+ .waitFor({ state: 'visible', timeout: 5_000 })
+ .then(() => true)
+ .catch(() => false);
+ if (!rendered) {
+ throw new Error(`Expected the member Property directory to render: ${await memberPropertyRouteDiagnostic(page)}`);
+ }
+ const exists = await list.hasPropertyNamed(propertyName);
+ if (!exists) {
+ throw new Error(`Expected the member Property directory to include "${propertyName}", but it listed: ${(await list.listedPropertyNames()).join(', ')}`);
+ }
+ return true;
+ });
+
+/** Reports the form interaction result captured by the member route task. */
+export const MemberPropertyOperationSucceeds = () =>
+ Question.about('whether the member Property operation succeeds', async (serenityActor) => {
+ const actor = serenityActor as unknown as Actor;
+ const status = await actor.answer(notes().get('lastOperationStatus'));
+ if (status !== 'SUCCESS') {
+ const error = await actor.answer(notes().get('lastOperationError'));
+ throw new Error(`Expected the member Property operation to succeed, but status was "${status ?? 'not recorded'}"${error ? `: ${error}` : ''}`);
+ }
+ return true;
+ });
+
+/** Checks the allowed persisted listing-content mutation. */
+export const MemberPropertyListingFlagEquals = (propertyName: string, flagName: string, expectedValue: string) =>
+ Question.about(`whether "${propertyName}" has ${flagName}=${expectedValue}`, async (serenityActor) => {
+ const actor = serenityActor as unknown as Actor;
+ const communityId = await actor.answer(notes().get('communityId'));
+ const property = await findMemberPropertyFixtureRecord({ connectionString: mongoConnectionString(), dbName: mongoDbName }, communityId, propertyName);
+ if (!property) {
+ throw new Error(`Expected "${propertyName}" to exist while checking its ${flagName}`);
+ }
+ if (flagName !== 'listedInDirectory') {
+ throw new Error(`Unsupported member Property listing flag assertion "${flagName}"`);
+ }
+ const expected = expectedValue === 'true';
+ if (property.listedInDirectory !== expected) {
+ throw new Error(`Expected "${propertyName}" ${flagName} to be ${expected}, but it was ${property.listedInDirectory}`);
+ }
+ return true;
+ });
+
+/** A foreign detail must provide information without exposing edit inputs or owner identity. */
+export const MemberPropertyDetailIsReadOnly = () =>
+ Question.about('whether the member Property detail is read-only', async (serenityActor) => {
+ const actor = serenityActor as unknown as Actor;
+ const page = await memberPropertyPortalPageOf(actor);
+ const detail = memberPropertyReadOnlyDetailOn(page);
+ const rendered = await detail.heading
+ .waitFor({ state: 'visible', timeout: 5_000 })
+ .then(() => true)
+ .catch(() => false);
+ if (!rendered) {
+ throw new Error(`Expected a read-only member Property detail to render: ${await memberPropertyRouteDiagnostic(page)}`);
+ }
+ if (await detail.hasEditableControls()) {
+ throw new Error('Expected the foreign member Property detail to be read-only, but editable controls were visible');
+ }
+ return true;
+ });
+
+export const MemberPropertyDetailHidesForeignOwnerIdentity = () =>
+ Question.about('whether the foreign owner identity is hidden', async (serenityActor) => {
+ const actor = serenityActor as unknown as Actor;
+ const page = await memberPropertyPortalPageOf(actor);
+ if (await memberPropertyReadOnlyDetailOn(page).displaysOwnerIdentity('Other Owner')) {
+ throw new Error('Expected the foreign owner identity to be hidden from the member Property detail');
+ }
+ return true;
+ });
+
+/** Verifies that an ineligible visitor did not obtain the directory view. */
+export const MemberPropertyRouteIsDenied = () =>
+ Question.about('whether the member Property route is denied', async (serenityActor) => {
+ const actor = serenityActor as unknown as Actor;
+ const page = await memberPropertyPortalPageOf(actor);
+ if (await memberDirectoryIsRendered(actor)) {
+ throw new Error(`Expected the member Property route to be denied, but the directory rendered: ${await memberPropertyRouteDiagnostic(page)}`);
+ }
+ const pathname = new URL(page.url()).pathname;
+ if (!pathname.startsWith(await memberBasePathOf(actor))) {
+ return true;
+ }
+ const denied = await page
+ .getByText(/access denied|forbidden|not authorized|not found/i)
+ .isVisible()
+ .catch(() => false);
+ if (!denied) {
+ throw new Error(`Expected the member Property route to render an explicit denial or redirect away from the member subtree: ${await memberPropertyRouteDiagnostic(page)}`);
+ }
+ return true;
+ });
+
+/** Verifies manager routing lands at the established admin Properties directory. */
+export const IsRedirectedToAdminPropertyDirectory = () =>
+ Question.about('whether the visitor was redirected to the admin Property directory', async (serenityActor) => {
+ const actor = serenityActor as unknown as Actor;
+ const page = await memberPropertyPortalPageOf(actor);
+ const expectedPath = `${await adminBasePathOfMemberProperty(actor)}/properties`;
+ const redirected =
+ new URL(page.url()).pathname === expectedPath ||
+ (await page
+ .waitForURL((url) => url.pathname === expectedPath, { timeout: 5_000 })
+ .then(() => true)
+ .catch(() => false));
+ if (!redirected) {
+ const pathname = new URL(page.url()).pathname;
+ throw new Error(`Expected a manager member-Property URL to redirect to "${expectedPath}", but got "${pathname}": ${await memberPropertyRouteDiagnostic(page)}`);
+ }
+ return true;
+ });
diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/step-definitions/index.ts
new file mode 100644
index 000000000..6da6a6691
--- /dev/null
+++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/step-definitions/index.ts
@@ -0,0 +1 @@
+import './member-property.steps.ts';
diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/step-definitions/member-property.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/step-definitions/member-property.steps.ts
new file mode 100644
index 000000000..6120d539a
--- /dev/null
+++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/step-definitions/member-property.steps.ts
@@ -0,0 +1,100 @@
+import { AfterAll, Given, Then, When } from '@cucumber/cucumber';
+import { actorCalled, actorInTheSpotlight } from '@serenity-js/core';
+import { closeMemberPropertyPortalSessions } from '../abilities/member-property-portal-page.ts';
+import { OpenMemberPropertyDetail } from '../interactions/open-member-property-detail.ts';
+import {
+ IsRedirectedToAdminPropertyDirectory,
+ MemberPropertyDetailHidesForeignOwnerIdentity,
+ MemberPropertyDetailIsReadOnly,
+ MemberPropertyDirectoryIncludes,
+ MemberPropertyListingFlagEquals,
+ MemberPropertyOperationSucceeds,
+ MemberPropertyRouteIsDenied,
+} from '../questions/member-property-screen.ts';
+import { ArrangeForeignMemberProperty, ArrangeOwnMemberProperty, MakeForeignMemberAvailable } from '../tasks/arrange-member-property.ts';
+import { EstablishMemberPropertyCommunity } from '../tasks/establish-member-property-community.ts';
+import { BecomeMemberPropertyManager, BecomeMemberPropertyRouteVisitor, type MemberPropertyRouteVisitor } from '../tasks/member-property-personas.ts';
+import { CreateMemberPropertyViaForm, UpdateMemberPropertyListingFlagViaForm, ViewMemberPropertyDirectory } from '../tasks/member-property-route.ts';
+
+const visitors: ReadonlySet = new Set(['no-permission', 'no-role', 'nonaccepted', 'guest', 'mismatched-route']);
+
+Given('Maya is an accepted own-property member in a member Property community', async () => {
+ await actorCalled('Maya').attemptsTo(EstablishMemberPropertyCommunity.forOwnPropertyMember());
+});
+
+Given('a foreign same-community member owns the member Property {string}', async (propertyName: string) => {
+ await actorCalled('Maya').attemptsTo(MakeForeignMemberAvailable(), ArrangeForeignMemberProperty(propertyName));
+});
+
+Given('Maya owns the member Property {string}', async (propertyName: string) => {
+ await actorCalled('Maya').attemptsTo(ArrangeOwnMemberProperty(propertyName));
+});
+
+Given("{word} is a {string} member Property route visitor in Maya's community", async (actorName: string, visitorName: string) => {
+ if (!visitors.has(visitorName as MemberPropertyRouteVisitor)) {
+ throw new Error(`Unknown member Property route visitor "${visitorName}"`);
+ }
+ await actorCalled(actorName).attemptsTo(BecomeMemberPropertyRouteVisitor.inMayaCommunity(visitorName as MemberPropertyRouteVisitor, actorCalled('Maya')));
+});
+
+Given("Morgan is the property manager of Maya's member Property community", async () => {
+ await actorCalled('Morgan').attemptsTo(BecomeMemberPropertyManager.inMayaCommunity(actorCalled('Maya')));
+});
+
+When('Maya opens the member Property directory', async () => {
+ await actorCalled('Maya').attemptsTo(ViewMemberPropertyDirectory());
+});
+
+When('{word} opens their member Property directory', async (actorName: string) => {
+ await actorCalled(actorName).attemptsTo(ViewMemberPropertyDirectory());
+});
+
+When('Maya creates a member Property named {string}', async (propertyName: string) => {
+ await actorCalled('Maya').attemptsTo(CreateMemberPropertyViaForm(propertyName));
+});
+
+When('Maya opens the member Property details for {string}', async (propertyName: string) => {
+ await actorCalled('Maya').attemptsTo(OpenMemberPropertyDetail(propertyName));
+});
+
+When('Maya sets the member Property {string} listing flag {string} to {string}', async (propertyName: string, flagName: string, value: string) => {
+ if (flagName !== 'listedInDirectory') {
+ throw new Error(`Unsupported member-editable listing flag "${flagName}"`);
+ }
+ if (value !== 'true' && value !== 'false') {
+ throw new Error(`Expected a boolean member listing flag value, but received "${value}"`);
+ }
+ await actorCalled('Maya').attemptsTo(UpdateMemberPropertyListingFlagViaForm(propertyName, value === 'true'));
+});
+
+Then('the member Property directory includes {string}', async (propertyName: string) => {
+ await actorInTheSpotlight().answer(MemberPropertyDirectoryIncludes(propertyName));
+});
+
+Then('the member Property operation succeeds', async () => {
+ await actorInTheSpotlight().answer(MemberPropertyOperationSucceeds());
+});
+
+Then('the member Property detail is read-only', async () => {
+ await actorInTheSpotlight().answer(MemberPropertyDetailIsReadOnly());
+});
+
+Then('the member Property detail does not display the foreign owner identity', async () => {
+ await actorInTheSpotlight().answer(MemberPropertyDetailHidesForeignOwnerIdentity());
+});
+
+Then('the member Property {string} has listing flag {string} set to {string}', async (propertyName: string, flagName: string, value: string) => {
+ await actorInTheSpotlight().answer(MemberPropertyListingFlagEquals(propertyName, flagName, value));
+});
+
+Then('{word} is denied the member Property route', async (actorName: string) => {
+ await actorCalled(actorName).answer(MemberPropertyRouteIsDenied());
+});
+
+Then('Morgan is redirected to the admin Property directory', async () => {
+ await actorCalled('Morgan').answer(IsRedirectedToAdminPropertyDirectory());
+});
+
+AfterAll(async () => {
+ await closeMemberPropertyPortalSessions();
+});
diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/arrange-member-property.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/arrange-member-property.ts
new file mode 100644
index 000000000..1a093ec1a
--- /dev/null
+++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/arrange-member-property.ts
@@ -0,0 +1,54 @@
+import { actors, END_USER_IDS, provisionMemberPropertyFixture } from '@ocom-verification/verification-shared/test-data';
+import { type Actor, Interaction, notes, Task, the } from '@serenity-js/core';
+import { mongoConnectionString, mongoDbName } from '../../../servers/test-mongo-server.ts';
+import { OpenPropertyDetail } from '../../property/interactions/open-property-detail.ts';
+import { SelectPropertyOwner } from '../../property/interactions/select-property-owner.ts';
+import { SubmitPropertySave } from '../../property/interactions/submit-property-save.ts';
+import { CreatePropertyViaForm } from '../../property/tasks/create-property.ts';
+import { RecordMemberPropertyId } from '../interactions/record-member-property-id.ts';
+import type { MemberPropertyE2ENotes } from '../notes/member-property-notes.ts';
+import { FOREIGN_MEMBER_NAME, MAYA_MEMBER_NAME } from './establish-member-property-community.ts';
+
+/** Provisions the foreign same-community owner required by read-only detail scenarios. */
+export const MakeForeignMemberAvailable = () =>
+ Interaction.where(the`#actor makes a foreign same-community member available`, async (serenityActor) => {
+ const actor = serenityActor as unknown as Actor;
+ const communityId = await actor.answer(notes().get('communityId'));
+ await provisionMemberPropertyFixture(
+ { connectionString: mongoConnectionString(), dbName: mongoDbName },
+ {
+ communityId,
+ endUserId: END_USER_IDS.otherCommunityOwner,
+ memberName: FOREIGN_MEMBER_NAME,
+ firstName: actors.OtherCommunityOwner.givenName,
+ lastName: actors.OtherCommunityOwner.familyName,
+ accountStatus: 'ACCEPTED',
+ canEditOwnProperty: false,
+ canManageProperties: false,
+ },
+ );
+ });
+
+/**
+ * Reuses the live admin Property workflow to arrange fixtures, preserving the
+ * existing manager regression surface while member routes are added later.
+ */
+export const ArrangeForeignMemberProperty = (propertyName: string) =>
+ Task.where(
+ the`#actor arranges a foreign member Property "${propertyName}"`,
+ CreatePropertyViaForm({ propertyName }),
+ OpenPropertyDetail(propertyName),
+ SelectPropertyOwner(FOREIGN_MEMBER_NAME),
+ SubmitPropertySave(),
+ RecordMemberPropertyId(propertyName),
+ );
+
+export const ArrangeOwnMemberProperty = (propertyName: string) =>
+ Task.where(
+ the`#actor arranges an own member Property "${propertyName}"`,
+ CreatePropertyViaForm({ propertyName }),
+ OpenPropertyDetail(propertyName),
+ SelectPropertyOwner(MAYA_MEMBER_NAME),
+ SubmitPropertySave(),
+ RecordMemberPropertyId(propertyName),
+ );
diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/establish-member-property-community.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/establish-member-property-community.ts
new file mode 100644
index 000000000..d24cf5e21
--- /dev/null
+++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/establish-member-property-community.ts
@@ -0,0 +1,65 @@
+import { actors, END_USER_IDS, provisionMemberPropertyFixture } from '@ocom-verification/verification-shared/test-data';
+import { type Actor, notes, Task } from '@serenity-js/core';
+import { mongoConnectionString, mongoDbName } from '../../../servers/test-mongo-server.ts';
+import type { PropertyE2ENotes } from '../../property/notes/property-notes.ts';
+import { BecomePropertyManager } from '../../property/tasks/become-property-manager.ts';
+import type { MemberPropertyE2ENotes } from '../notes/member-property-notes.ts';
+
+export const MAYA_MEMBER_NAME = 'Maya Own-Property Member';
+export const FOREIGN_MEMBER_NAME = 'Foreign Same-Community Owner';
+
+interface MemberPropertyCommunityIds {
+ communityId: string;
+ managerMemberId: string;
+ adminBasePath: string;
+}
+
+const parseAdminBasePath = (path: string): MemberPropertyCommunityIds => {
+ const match = path.match(/^\/community\/([a-f0-9]{24})\/admin\/([a-f0-9]{24})$/i);
+ const communityId = match?.[1];
+ const managerMemberId = match?.[2];
+ if (!communityId || !managerMemberId) {
+ throw new Error(`Expected the manager setup to record an admin portal path, but got "${path}"`);
+ }
+ return { communityId, managerMemberId, adminBasePath: path };
+};
+
+/** Creates a manager community through the UI and provisions Maya as its own-property member fixture. */
+export class EstablishMemberPropertyCommunity extends Task {
+ static forOwnPropertyMember(): EstablishMemberPropertyCommunity {
+ return new EstablishMemberPropertyCommunity();
+ }
+
+ private constructor() {
+ super('becomes an accepted own-property member in a member Property community');
+ }
+
+ async performAs(actor: Actor): Promise {
+ await actor.attemptsTo(BecomePropertyManager());
+ const adminBasePath = await actor.answer(notes().get('adminBasePath'));
+ const community = parseAdminBasePath(adminBasePath);
+ const ownMember = await provisionMemberPropertyFixture(
+ { connectionString: mongoConnectionString(), dbName: mongoDbName },
+ {
+ communityId: community.communityId,
+ endUserId: END_USER_IDS.communityMember,
+ memberName: MAYA_MEMBER_NAME,
+ firstName: actors.CommunityMember.givenName,
+ lastName: actors.CommunityMember.familyName,
+ accountStatus: 'ACCEPTED',
+ canEditOwnProperty: true,
+ canManageProperties: false,
+ },
+ );
+ await actor.attemptsTo(
+ notes().set('visitor', 'own-editor'),
+ notes().set('communityId', community.communityId),
+ notes().set('managerMemberId', community.managerMemberId),
+ notes().set('ownMemberId', ownMember.memberId),
+ notes().set('routeMemberId', ownMember.memberId),
+ notes().set('memberBasePath', `/community/${community.communityId}/member/${ownMember.memberId}`),
+ notes().set('adminBasePath', community.adminBasePath),
+ notes().set('memberPropertyIds', {}),
+ );
+ }
+}
diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/member-property-personas.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/member-property-personas.ts
new file mode 100644
index 000000000..1e13d0db9
--- /dev/null
+++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/member-property-personas.ts
@@ -0,0 +1,89 @@
+import { actors, END_USER_IDS, provisionMemberPropertyFixture } from '@ocom-verification/verification-shared/test-data';
+import { type Actor, notes, Task } from '@serenity-js/core';
+import { mongoConnectionString, mongoDbName } from '../../../servers/test-mongo-server.ts';
+import type { MemberPropertyBrowserVisitor, MemberPropertyE2ENotes } from '../notes/member-property-notes.ts';
+
+export type MemberPropertyRouteVisitor = 'no-permission' | 'no-role' | 'nonaccepted' | 'guest' | 'mismatched-route';
+
+interface MemberPropertyCommunityRoute {
+ communityId: string;
+ managerMemberId: string;
+ ownMemberId: string;
+ adminBasePath: string;
+}
+
+const routeContextFrom = async (actor: Actor): Promise => ({
+ communityId: await actor.answer(notes().get('communityId')),
+ managerMemberId: await actor.answer(notes().get('managerMemberId')),
+ ownMemberId: await actor.answer(notes().get('ownMemberId')),
+ adminBasePath: await actor.answer(notes().get('adminBasePath')),
+});
+
+async function recordBrowserVisitor(actor: Actor, visitor: MemberPropertyBrowserVisitor, context: MemberPropertyCommunityRoute, routeMemberId: string): Promise {
+ await actor.attemptsTo(
+ notes().set('visitor', visitor),
+ notes().set('communityId', context.communityId),
+ notes().set('managerMemberId', context.managerMemberId),
+ notes().set('ownMemberId', context.ownMemberId),
+ notes().set('routeMemberId', routeMemberId),
+ notes().set('memberBasePath', `/community/${context.communityId}/member/${routeMemberId}`),
+ notes().set('adminBasePath', context.adminBasePath),
+ notes().set('memberPropertyIds', {}),
+ );
+}
+
+/** Configures a fresh browser actor to use the manager membership from Maya's community. */
+export class BecomeMemberPropertyManager extends Task {
+ static inMayaCommunity(maya: Actor): BecomeMemberPropertyManager {
+ return new BecomeMemberPropertyManager(maya);
+ }
+
+ private constructor(private readonly maya: Actor) {
+ super('becomes the property manager of Maya’s member Property community');
+ }
+
+ async performAs(actor: Actor): Promise {
+ const context = await routeContextFrom(this.maya);
+ await recordBrowserVisitor(actor, 'manager', context, context.managerMemberId);
+ }
+}
+
+/** Configures a browser actor as an ineligible visitor of Maya's member route. */
+export class BecomeMemberPropertyRouteVisitor extends Task {
+ static inMayaCommunity(visitor: MemberPropertyRouteVisitor, maya: Actor): BecomeMemberPropertyRouteVisitor {
+ return new BecomeMemberPropertyRouteVisitor(visitor, maya);
+ }
+
+ private constructor(
+ private readonly visitor: MemberPropertyRouteVisitor,
+ private readonly maya: Actor,
+ ) {
+ super(`becomes a ${visitor} member Property route visitor`);
+ }
+
+ async performAs(actor: Actor): Promise {
+ const context = await routeContextFrom(this.maya);
+ let routeMemberId = context.ownMemberId;
+ if (this.visitor === 'no-permission' || this.visitor === 'no-role' || this.visitor === 'nonaccepted') {
+ const member = await provisionMemberPropertyFixture(
+ { connectionString: mongoConnectionString(), dbName: mongoDbName },
+ {
+ communityId: context.communityId,
+ endUserId: END_USER_IDS.otherCommunityOwner,
+ memberName: `${actor.name} Member Property Visitor`,
+ firstName: actors.OtherCommunityOwner.givenName,
+ lastName: actors.OtherCommunityOwner.familyName,
+ accountStatus: this.visitor === 'nonaccepted' ? 'CREATED' : 'ACCEPTED',
+ canEditOwnProperty: false,
+ canManageProperties: false,
+ hasRole: this.visitor !== 'no-role',
+ },
+ );
+ routeMemberId = member.memberId;
+ }
+ if (this.visitor === 'mismatched-route') {
+ routeMemberId = '65e1a77bcf86cd79943900ff';
+ }
+ await recordBrowserVisitor(actor, this.visitor, context, routeMemberId);
+ }
+}
diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/member-property-route.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/member-property-route.ts
new file mode 100644
index 000000000..3322ec251
--- /dev/null
+++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/member-property-route.ts
@@ -0,0 +1,98 @@
+import { type Actor, Interaction, notes, Task, the } from '@serenity-js/core';
+import { memberPropertiesListOn, memberPropertyFormOn, memberPropertyPortalPageOf, memberPropertyRouteDiagnostic } from '../abilities/member-property-portal-page.ts';
+import { OpenMemberPropertyDetail } from '../interactions/open-member-property-detail.ts';
+import { OpenMemberPropertyDirectory } from '../interactions/open-member-property-directory.ts';
+import { RecordMemberPropertyId } from '../interactions/record-member-property-id.ts';
+import type { MemberPropertyE2ENotes } from '../notes/member-property-notes.ts';
+
+const responseErrorMessage = (payload: unknown): string => {
+ if (!payload || typeof payload !== 'object') {
+ return 'The GraphQL response had no mutation payload';
+ }
+ const errors = (payload as { errors?: unknown }).errors;
+ if (Array.isArray(errors) && errors.length > 0) {
+ return errors.map((error) => (error && typeof error === 'object' && 'message' in error ? String(error.message) : String(error))).join(', ');
+ }
+ return 'The Property mutation did not report success';
+};
+
+const waitForSuccessfulMemberPropertyMutation = async (page: Awaited>, operationName: string, resultField: 'propertyCreate' | 'propertyUpdate'): Promise => {
+ const response = await page.waitForResponse((candidate) => candidate.url().includes('/api/graphql') && candidate.request().method() === 'POST' && candidate.request().postData()?.includes(operationName) === true, {
+ timeout: 5_000,
+ });
+ if (!response.ok()) {
+ throw new Error(`The ${operationName} request failed with HTTP ${response.status()}`);
+ }
+ const payload = (await response.json()) as unknown;
+ const results = Array.isArray(payload) ? payload : [payload];
+ const mutationResult = results.find((result): result is { data?: Record } =>
+ Boolean(result && typeof result === 'object' && 'data' in result && (result as { data?: Record }).data?.[resultField]),
+ );
+ const status = mutationResult?.data?.[resultField]?.status;
+ if (!status?.success) {
+ throw new Error(status?.errorMessage ?? responseErrorMessage(results.find((result) => result && typeof result === 'object' && 'errors' in result)));
+ }
+};
+
+async function requireDirectory(actor: Actor): Promise {
+ const page = await memberPropertyPortalPageOf(actor);
+ const list = memberPropertiesListOn(page);
+ const rendered = await list.heading
+ .waitFor({ state: 'visible', timeout: 5_000 })
+ .then(() => true)
+ .catch(() => false);
+ if (!rendered) {
+ throw new Error(`The member Property directory is not implemented: ${await memberPropertyRouteDiagnostic(page)}`);
+ }
+}
+
+/** Opens the real route without hiding its Phase 1 missing-feature red baseline. */
+export const ViewMemberPropertyDirectory = () => Task.where(the`#actor views the member Property directory`, OpenMemberPropertyDirectory());
+
+export const CreateMemberPropertyViaForm = (propertyName: string) =>
+ Task.where(
+ the`#actor creates the member Property "${propertyName}"`,
+ notes().set('lastOperationStatus', undefined),
+ notes().set('lastOperationError', undefined),
+ OpenMemberPropertyDirectory(),
+ Interaction.where(the`#actor submits the member Property create form`, async (serenityActor) => {
+ const actor = serenityActor as unknown as Actor;
+ try {
+ await requireDirectory(actor);
+ const page = await memberPropertyPortalPageOf(actor);
+ await memberPropertiesListOn(page).clickAddProperty();
+ const form = memberPropertyFormOn(page);
+ await form.propertyNameInput.waitFor({ state: 'visible', timeout: 5_000 });
+ await form.fillPropertyName(propertyName);
+ const creation = waitForSuccessfulMemberPropertyMutation(page, 'MemberPropertyCreate', 'propertyCreate');
+ await form.clickCreate();
+ await creation;
+ await actor.attemptsTo(RecordMemberPropertyId(propertyName));
+ await actor.attemptsTo(notes().set('lastOperationStatus', 'SUCCESS'));
+ } catch (error) {
+ await actor.attemptsTo(notes().set('lastOperationStatus', 'FAILURE'), notes().set('lastOperationError', error instanceof Error ? error.message : String(error)));
+ }
+ }),
+ );
+
+export const UpdateMemberPropertyListingFlagViaForm = (propertyName: string, value: boolean) =>
+ Task.where(
+ the`#actor updates the member Property "${propertyName}"`,
+ notes().set('lastOperationStatus', undefined),
+ notes().set('lastOperationError', undefined),
+ OpenMemberPropertyDetail(propertyName),
+ Interaction.where(the`#actor saves the member-editable listing flag`, async (serenityActor) => {
+ const actor = serenityActor as unknown as Actor;
+ try {
+ const page = await memberPropertyPortalPageOf(actor);
+ const form = memberPropertyFormOn(page);
+ await form.setListingFlag('listedInDirectory', value);
+ const update = waitForSuccessfulMemberPropertyMutation(page, 'MemberPropertyUpdate', 'propertyUpdate');
+ await form.clickSave();
+ await update;
+ await actor.attemptsTo(notes().set('lastOperationStatus', 'SUCCESS'));
+ } catch (error) {
+ await actor.attemptsTo(notes().set('lastOperationStatus', 'FAILURE'), notes().set('lastOperationError', error instanceof Error ? error.message : String(error)));
+ }
+ }),
+ );
diff --git a/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts b/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts
index 9602aed37..64eae809b 100644
--- a/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts
+++ b/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts
@@ -3,7 +3,7 @@ import { getMongoPort } from '@ocom-verification/verification-shared/environment
import { seedDatabase } from '@ocom-verification/verification-shared/test-data';
import { appPaths } from '../shared/environment/app-paths.ts';
-const mongoDbName = 'owner-community';
+export const mongoDbName = 'owner-community';
const mongoReplSetName = 'globaldb';
export const testMongoServer = new MongoMemoryProcessTestServer({
@@ -18,9 +18,9 @@ export const testMongoServer = new MongoMemoryProcessTestServer({
readyMarker: 'MongoDB Memory Replica Set ready at:',
seedData: seedDatabase,
serverName: 'TestMongoMemoryServer',
- spawnArgs: () => ['run', process.env['WORKTREE_NAME'] ? 'dev:worktree' : 'dev'],
+ spawnArgs: () => ['run', process.env.WORKTREE_NAME ? 'dev:worktree' : 'dev'],
});
-function mongoConnectionString(): string {
+export function mongoConnectionString(): string {
return `mongodb://127.0.0.1:${getMongoPort()}/${mongoDbName}?replicaSet=${mongoReplSetName}`;
}
diff --git a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts
index 2b91f89a9..a177c6aa9 100644
--- a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts
+++ b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts
@@ -5,6 +5,7 @@
import '../contexts/community/step-definitions/index.ts';
import '../contexts/property/step-definitions/index.ts';
+import '../contexts/member-property/step-definitions/index.ts';
import '../contexts/staff/step-definitions/index.ts';
import '../contexts/staff-role/step-definitions/index.ts';
import '../contexts/authentication/step-definitions/index.ts';
diff --git a/packages/ocom-verification/verification-shared/src/pages/index.ts b/packages/ocom-verification/verification-shared/src/pages/index.ts
index ccf4b8b14..ad08918c1 100644
--- a/packages/ocom-verification/verification-shared/src/pages/index.ts
+++ b/packages/ocom-verification/verification-shared/src/pages/index.ts
@@ -1,5 +1,7 @@
export { CommunityPage } from './community.page.ts';
export { HomePage } from './home.page.ts';
+export { MemberPropertiesListPage } from './member-properties-list.page.ts';
+export { MemberPropertyReadOnlyDetailPage } from './member-property-read-only-detail.page.ts';
export { PropertiesListPage } from './properties-list.page.ts';
export { type FieldAdornment, type PropertyAddressSelectFieldKey, PropertyFormPage, type PropertyFormTextFieldKey, type PropertyListingFlagKey, stateCodeForName, stateNameForCode, US_STATE_OPTIONS } from './property-form.page.ts';
export { STAFF_ROLE_PERMISSION_LABELS, StaffRoleFormPage } from './staff-role-form.page.ts';
diff --git a/packages/ocom-verification/verification-shared/src/pages/member-properties-list.page.ts b/packages/ocom-verification/verification-shared/src/pages/member-properties-list.page.ts
new file mode 100644
index 000000000..c623edb40
--- /dev/null
+++ b/packages/ocom-verification/verification-shared/src/pages/member-properties-list.page.ts
@@ -0,0 +1,12 @@
+import type { ElementHandle } from '@cellix/serenity-framework/pages';
+import { PropertiesListPage } from './properties-list.page.ts';
+
+/**
+ * Page contract for the member directory. It shares table interactions with
+ * the manager directory but deliberately does not expose owner-column APIs.
+ */
+export class MemberPropertiesListPage extends PropertiesListPage {
+ override get heading(): ElementHandle {
+ return this.adapter.getByText(/Community Properties \(/);
+ }
+}
diff --git a/packages/ocom-verification/verification-shared/src/pages/member-property-read-only-detail.page.ts b/packages/ocom-verification/verification-shared/src/pages/member-property-read-only-detail.page.ts
new file mode 100644
index 000000000..a1f793ba7
--- /dev/null
+++ b/packages/ocom-verification/verification-shared/src/pages/member-property-read-only-detail.page.ts
@@ -0,0 +1,34 @@
+import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages';
+
+/**
+ * Page contract for a member's read-only view of another member's property.
+ * This intentionally has no editable-form APIs: foreign properties must never
+ * infer editability from an owner field rendered in the page.
+ */
+export class MemberPropertyReadOnlyDetailPage extends AdapterBackedPageObject {
+ get heading(): ElementHandle {
+ return this.adapter.getByText(/^Property Details$/);
+ }
+
+ get editableControls(): ElementHandle {
+ return this.adapter.locator('input, textarea, [role="combobox"], [role="switch"]');
+ }
+
+ ownerIdentityNamed(memberName: string): ElementHandle {
+ return this.adapter.getByText(memberName);
+ }
+
+ async hasEditableControls(): Promise {
+ if ((await this.adapter.locatorAll('input, textarea, [role="combobox"], [role="switch"]')).length > 0) {
+ return true;
+ }
+ return await this.adapter
+ .getByRole('button', { name: /^(?:Edit|Save|Remove|Delete)$/i })
+ .isVisible()
+ .catch(() => false);
+ }
+
+ async displaysOwnerIdentity(memberName: string): Promise {
+ return await this.ownerIdentityNamed(memberName).isVisible();
+ }
+}
diff --git a/packages/ocom-verification/verification-shared/src/scenarios/property/member-property-authorization.feature b/packages/ocom-verification/verification-shared/src/scenarios/property/member-property-authorization.feature
new file mode 100644
index 000000000..9473650a4
--- /dev/null
+++ b/packages/ocom-verification/verification-shared/src/scenarios/property/member-property-authorization.feature
@@ -0,0 +1,110 @@
+@member-property @api-only
+Feature: Member Property API authorization
+
+ As a community
+ I want member Property requests authorized by accepted membership, community, and ownership
+ So that members can edit their own listings without gaining directory-manager powers
+
+ Background:
+ Given Maya is an accepted own-property member in a member Property community
+
+ Scenario: A foreign same-community update is masked as a missing property
+ Given a foreign same-community member owns the member Property "Neighbor Cottage"
+ And Morgan is the property manager of Maya's member Property community
+ When Maya attempts to set the member Property "Neighbor Cottage" listing flag "listedInDirectory" to "true"
+ Then the member Property operation is rejected
+ And the member Property error is exactly "Property not found"
+ When Maya attempts to update an unknown member Property
+ Then the member Property error is exactly "Property not found"
+ And Morgan sees the member Property "Neighbor Cottage" listing flag "listedInDirectory" set to "false"
+
+ Scenario: An owner cannot mix a permitted update with a manager-only field
+ Given Maya owns the member Property "Maya Listing"
+ When Maya attempts to update the member Property "Maya Listing" with:
+ | propertyName | Renamed Listing |
+ | listedInDirectory | true |
+ Then the member Property operation is rejected
+ And the member Property "Maya Listing" has listing flag "listedInDirectory" set to "false"
+ And no member Property named "Renamed Listing" is created
+
+ Scenario: An owner cannot change type, owner, or delete a property
+ Given Maya owns the member Property "Maya Listing"
+ When Maya attempts to update the member Property "Maya Listing" with:
+ | propertyType | condo |
+ Then the member Property operation is rejected
+ And the member Property "Maya Listing" has no property type
+ And a foreign same-community member is available for member Property ownership
+ When Maya attempts to update the member Property "Maya Listing" with:
+ | ownerId | foreign |
+ Then the member Property operation is rejected
+ When Maya attempts to update the member Property "Maya Listing" with:
+ | ownerId | clear |
+ Then the member Property operation is rejected
+ And the member Property "Maya Listing" remains owned by Maya
+ When Maya attempts to delete the member Property "Maya Listing"
+ Then the member Property operation is rejected
+ And the member Property "Maya Listing" remains active
+ And the member Property "Maya Listing" remains in Maya's community
+
+ Scenario Outline: A supplied owner id cannot create a member Property
+ Given a foreign same-community member is available for member Property ownership
+ When Maya attempts to create a member Property named "Spoofed " with supplied owner id ""
+ Then the member Property operation is rejected
+ And no member Property named "Spoofed " is created
+
+ Examples:
+ | owner |
+ | self |
+ | foreign |
+
+ Scenario Outline: An ineligible visitor cannot list, read, create, or update member Properties
+ Given a foreign same-community member owns the member Property "Protected Cottage"
+ And is a "" member Property API visitor in Maya's community
+ When attempts to list Maya's member Property directory
+ Then the member Property operation is rejected
+ When attempts to read the member Property "Protected Cottage" from Maya's community
+ Then the member Property details are unavailable
+ When attempts to create a member Property named " Cabin"
+ Then the member Property operation is rejected
+ And no member Property named " Cabin" is created
+ When attempts to set the member Property "Protected Cottage" listing flag "listedInDirectory" to "true"
+ Then the member Property operation is rejected
+
+ Examples:
+ | actor | visitor |
+ | Nora | no-permission |
+ | Noah | no-role |
+ | Parker | created |
+ | Derek | rejected |
+ | Guest | guest |
+
+ Scenario: A member cannot access another community's properties and receives the missing-property result
+ Given a foreign same-community member owns the member Property "Protected Cottage"
+ And Quinn is an accepted own-property member of a separate member Property community
+ When Quinn attempts to list Maya's member Property directory
+ Then the member Property operation is rejected
+ When Quinn attempts to read the member Property "Protected Cottage" from Maya's community
+ Then the member Property details are unavailable
+ When Quinn attempts to set the member Property "Protected Cottage" listing flag "listedInDirectory" to "true"
+ Then the member Property error is exactly "Property not found"
+ When Quinn attempts to update an unknown member Property
+ Then the member Property error is exactly "Property not found"
+
+ Scenario: A manager retains all-owner Property management
+ Given Maya owns the member Property "Maya Listing"
+ And Morgan is the property manager of Maya's member Property community
+ When Morgan sets the member Property "Maya Listing" listing flag "listedInDirectory" to "true"
+ Then the member Property operation succeeds
+ And Morgan sees the member Property "Maya Listing" listing flag "listedInDirectory" set to "true"
+
+ Scenario: A manager-soft-deleted property is hidden and unavailable to a member
+ Given Maya owns the member Property "Retired Listing"
+ And Morgan is the property manager of Maya's member Property community
+ When Morgan deletes the member Property "Retired Listing"
+ Then the member Property operation succeeds
+ When Maya opens the member Property directory
+ Then the member Property directory does not include "Retired Listing"
+ When Maya attempts to read the member Property "Retired Listing" from Maya's community
+ Then the member Property details are unavailable
+ When Maya attempts to set the member Property "Retired Listing" listing flag "listedInDirectory" to "true"
+ Then the member Property operation is rejected
diff --git a/packages/ocom-verification/verification-shared/src/scenarios/property/member-property-management.feature b/packages/ocom-verification/verification-shared/src/scenarios/property/member-property-management.feature
new file mode 100644
index 000000000..8920b8475
--- /dev/null
+++ b/packages/ocom-verification/verification-shared/src/scenarios/property/member-property-management.feature
@@ -0,0 +1,63 @@
+@member-property
+Feature: Member Property management
+
+ As an accepted community member allowed to edit my own properties
+ I want to find community properties and manage only my own listing content
+ So that the directory is useful without giving me manager powers
+
+ Background:
+ Given Maya is an accepted own-property member in a member Property community
+
+ Scenario: An own-property member can enter the current community directory
+ Given a foreign same-community member owns the member Property "Neighbor Cottage"
+ When Maya opens the member Property directory
+ Then the member Property directory includes "Neighbor Cottage"
+
+ @api-only
+ Scenario: An own-property member can retrieve a foreign same-community property
+ Given a foreign same-community member owns the member Property "Neighbor Cottage"
+ When Maya reads the member Property "Neighbor Cottage" from Maya's community
+ Then the member Property details are available
+
+ Scenario: An own-property member creates a property through the member route
+ When Maya creates a member Property named "Maya Cottage"
+ Then the member Property operation succeeds
+
+ @api-only
+ Scenario: The server persists a created member Property with the verified member as owner
+ When Maya creates a member Property named "Maya Cottage"
+ Then the member Property operation succeeds
+ And the member Property "Maya Cottage" is persisted with Maya as its owner
+
+ @skip-api
+ Scenario: A foreign same-community property is read-only and hides its owner's identity
+ Given a foreign same-community member owns the member Property "Neighbor Cottage"
+ When Maya opens the member Property details for "Neighbor Cottage"
+ Then the member Property detail is read-only
+ And the member Property detail does not display the foreign owner identity
+
+ Scenario: An owner updates an allowed listing-content field
+ Given Maya owns the member Property "Maya Listing"
+ When Maya sets the member Property "Maya Listing" listing flag "listedInDirectory" to "true"
+ Then the member Property operation succeeds
+ And the member Property "Maya Listing" has listing flag "listedInDirectory" set to "true"
+
+ @skip-api
+ Scenario Outline: Ineligible visitors cannot enter a member Property route
+ Given is a "" member Property route visitor in Maya's community
+ When opens their member Property directory
+ Then is denied the member Property route
+
+ Examples:
+ | actor | visitor |
+ | Nora | no-permission |
+ | Noah | no-role |
+ | Parker | nonaccepted |
+ | Guest | guest |
+ | Quinn | mismatched-route |
+
+ @skip-api
+ Scenario: A manager entering a member Property URL is redirected to the admin directory
+ Given Morgan is the property manager of Maya's member Property community
+ When Morgan opens their member Property directory
+ Then Morgan is redirected to the admin Property directory
diff --git a/packages/ocom-verification/verification-shared/src/test-data/index.ts b/packages/ocom-verification/verification-shared/src/test-data/index.ts
index b4a279858..abc62165b 100644
--- a/packages/ocom-verification/verification-shared/src/test-data/index.ts
+++ b/packages/ocom-verification/verification-shared/src/test-data/index.ts
@@ -1,3 +1,12 @@
+export {
+ findMemberPropertyFixtureRecord,
+ type MemberPropertyFixture,
+ type MemberPropertyFixtureAccountStatus,
+ type MemberPropertyFixtureDatabase,
+ type MemberPropertyFixtureIds,
+ type MemberPropertyPersistenceRecord,
+ provisionMemberPropertyFixture,
+} from './member-property-fixtures.ts';
export {
DEFAULT_STAFF_ROLE_NAMES,
END_USER_IDS,
diff --git a/packages/ocom-verification/verification-shared/src/test-data/member-property-fixtures.ts b/packages/ocom-verification/verification-shared/src/test-data/member-property-fixtures.ts
new file mode 100644
index 000000000..f43145e0e
--- /dev/null
+++ b/packages/ocom-verification/verification-shared/src/test-data/member-property-fixtures.ts
@@ -0,0 +1,200 @@
+import { MongoClient, MongoServerError, ObjectId } from 'mongodb';
+
+export type MemberPropertyFixtureAccountStatus = 'ACCEPTED' | 'CREATED' | 'REJECTED';
+
+export interface MemberPropertyFixture {
+ communityId: string;
+ endUserId: string;
+ memberName: string;
+ firstName: string;
+ lastName: string;
+ accountStatus: MemberPropertyFixtureAccountStatus;
+ canEditOwnProperty: boolean;
+ canManageProperties: boolean;
+ /** Omit the member role entirely for no-role access-denial arrangements. */
+ hasRole?: boolean;
+}
+
+export interface MemberPropertyFixtureIds {
+ memberId: string;
+ roleId: string | null;
+}
+
+export interface MemberPropertyFixtureDatabase {
+ connectionString: string;
+ dbName: string;
+}
+
+export interface MemberPropertyPersistenceRecord {
+ id: string;
+ propertyName: string;
+ ownerId: string | null;
+ propertyType: string | null;
+ listedInDirectory: boolean;
+ isDeleted: boolean;
+}
+
+const emptyPermissions = {
+ servicePermissions: { canManageServices: false },
+ serviceTicketPermissions: {
+ canCreateTickets: false,
+ canManageTickets: false,
+ canAssignTickets: false,
+ canWorkOnTickets: false,
+ },
+ violationTicketPermissions: {
+ canCreateTickets: false,
+ canManageTickets: false,
+ canAssignTickets: false,
+ canWorkOnTickets: false,
+ },
+ communityPermissions: {
+ canManageRolesAndPermissions: false,
+ canManageCommunitySettings: false,
+ canManageSiteContent: false,
+ canManageMembers: false,
+ canEditOwnMemberProfile: false,
+ canEditOwnMemberAccounts: false,
+ },
+};
+
+const MONGO_WRITE_RETRY_ATTEMPTS = 5;
+
+const delay = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds));
+
+/**
+ * Community creation provisions its default role asynchronously. Retrying a
+ * transient Mongo catalog write conflict keeps fixture setup deterministic
+ * without hiding non-transient fixture defects.
+ */
+async function retryTransientCatalogWrite(operation: () => Promise): Promise {
+ for (let attempt = 1; attempt <= MONGO_WRITE_RETRY_ATTEMPTS; attempt += 1) {
+ try {
+ return await operation();
+ } catch (error) {
+ const retryable = error instanceof MongoServerError && error.code === 112;
+ if (!retryable || attempt === MONGO_WRITE_RETRY_ATTEMPTS) {
+ throw error;
+ }
+ await delay(attempt * 50);
+ }
+ }
+ throw new Error('Transient Mongo fixture write retries were unexpectedly exhausted');
+}
+
+/**
+ * Creates a community-scoped member and, unless explicitly omitted, an
+ * end-user role for property scenarios.
+ * The member is linked to an already-seeded end user so API and browser tests
+ * can authenticate as the same fixture principal.
+ */
+export async function provisionMemberPropertyFixture(database: MemberPropertyFixtureDatabase, fixture: MemberPropertyFixture): Promise {
+ const client = new MongoClient(database.connectionString);
+ try {
+ await client.connect();
+ const db = client.db(database.dbName);
+ const now = new Date();
+ const communityId = new ObjectId(fixture.communityId);
+ const endUserId = new ObjectId(fixture.endUserId);
+ let roleId: string | null = null;
+ if (fixture.hasRole !== false) {
+ const roleName = `${fixture.memberName} Property Role`;
+ const role = await retryTransientCatalogWrite(
+ async () =>
+ await db.collection('roles').findOneAndReplace(
+ { roleType: 'end-user-roles', community: communityId, roleName },
+ {
+ roleType: 'end-user-roles',
+ community: communityId,
+ roleName,
+ isDefault: false,
+ permissions: {
+ ...emptyPermissions,
+ propertyPermissions: {
+ canManageProperties: fixture.canManageProperties,
+ canEditOwnProperty: fixture.canEditOwnProperty,
+ },
+ },
+ schemaVersion: '1.0.0',
+ createdAt: now,
+ updatedAt: now,
+ },
+ { upsert: true, returnDocument: 'after' },
+ ),
+ );
+ if (!role?._id) {
+ throw new Error(`Could not provision the "${roleName}" role for member Property fixtures`);
+ }
+ roleId = String(role._id);
+ }
+
+ const member = await retryTransientCatalogWrite(
+ async () =>
+ await db.collection('members').findOneAndReplace(
+ { community: communityId, memberName: fixture.memberName },
+ {
+ memberName: fixture.memberName,
+ community: communityId,
+ ...(roleId ? { role: new ObjectId(roleId) } : {}),
+ accounts: [
+ {
+ _id: new ObjectId(),
+ firstName: fixture.firstName,
+ lastName: fixture.lastName,
+ user: endUserId,
+ statusCode: fixture.accountStatus,
+ createdBy: endUserId,
+ createdAt: now,
+ updatedAt: now,
+ },
+ ],
+ customViews: [],
+ profile: {
+ interests: [],
+ showInterests: false,
+ showEmail: false,
+ showProfile: false,
+ showLocation: false,
+ showProperties: false,
+ },
+ schemaVersion: '1.0.0',
+ createdAt: now,
+ updatedAt: now,
+ },
+ { upsert: true, returnDocument: 'after' },
+ ),
+ );
+ if (!member?._id) {
+ throw new Error(`Could not provision the "${fixture.memberName}" member for member Property fixtures`);
+ }
+
+ return { roleId, memberId: String(member._id) };
+ } finally {
+ await client.close();
+ }
+}
+
+/** Reads the persisted property fields that member acceptance scenarios must verify without exposing UI-only projections. */
+export async function findMemberPropertyFixtureRecord(database: MemberPropertyFixtureDatabase, communityId: string, propertyName: string): Promise {
+ const client = new MongoClient(database.connectionString);
+ try {
+ await client.connect();
+ const property = await client
+ .db(database.dbName)
+ .collection('properties')
+ .findOne({ community: new ObjectId(communityId), propertyName });
+ if (!property?._id) {
+ return undefined;
+ }
+ return {
+ id: String(property._id),
+ propertyName: String(property.propertyName ?? ''),
+ ownerId: property.owner ? String(property.owner) : null,
+ propertyType: property.propertyType === undefined || property.propertyType === null ? null : String(property.propertyType),
+ listedInDirectory: property.listedInDirectory === true,
+ isDeleted: property.isDeleted === true,
+ };
+ } finally {
+ await client.close();
+ }
+}
diff --git a/packages/ocom/application-services/src/contexts/property/index.ts b/packages/ocom/application-services/src/contexts/property/index.ts
index 8918e9cf9..08f81c796 100644
--- a/packages/ocom/application-services/src/contexts/property/index.ts
+++ b/packages/ocom/application-services/src/contexts/property/index.ts
@@ -1,12 +1,12 @@
import type { DataSources } from '@ocom/persistence';
-import { Property as PropertyApi, type PropertyApplicationService } from './property/index.ts';
+import { Property as PropertyApi, type PropertyRequestContext as PropertyApiRequestContext, type PropertyApplicationService } from './property/index.ts';
export interface PropertyContextApplicationService {
Property: PropertyApplicationService;
}
-export const Property = (dataSources: DataSources): PropertyContextApplicationService => {
+export const Property = (dataSources: DataSources, requestContext?: PropertyApiRequestContext): PropertyContextApplicationService => {
return {
- Property: PropertyApi(dataSources),
+ Property: PropertyApi(dataSources, requestContext),
};
};
diff --git a/packages/ocom/application-services/src/contexts/property/property/apply-property-fields.ts b/packages/ocom/application-services/src/contexts/property/property/apply-property-fields.ts
index d781cfce6..e0bc1928b 100644
--- a/packages/ocom/application-services/src/contexts/property/property/apply-property-fields.ts
+++ b/packages/ocom/application-services/src/contexts/property/property/apply-property-fields.ts
@@ -55,9 +55,10 @@ export interface PropertyListingDetailFieldsCommand {
}
/**
- * User-manageable property fields shared by the create and update commands.
- * Every field is optional: `undefined` leaves the current value untouched and
- * an explicit `null` clears the value (where the domain allows clearing).
+ * Fields shared by create and update commands. Every field is optional:
+ * `undefined` leaves the current value untouched and an explicit `null`
+ * clears the value where the domain allows clearing. The aggregate and
+ * update preflight distinguish listing content from manager-only fields.
*/
export interface PropertyFieldsCommand {
propertyType?: string | null;
@@ -79,16 +80,16 @@ const resolveAddressField = (next: string | null | undefined, current: string):
};
async function applyOwner(dataSources: DataSources, property: PropertyAggregate, ownerId: string | null): Promise {
+ property.assertCanManageProperties();
if (ownerId === null) {
property.owner = null;
return;
}
- const member = await dataSources.readonlyDataSource.Community.Member.MemberReadRepo.getByIdWithRole(ownerId);
+ const member = await dataSources.readonlyDataSource.Community.Member.MemberReadRepo.getByIdInCommunityWithRole(ownerId, property.community.id);
if (!member) {
- throw new Error(`Owner member with id ${ownerId} not found`);
- }
- if (member.communityId !== property.community.id) {
- throw new Error("Owner member does not belong to the property's community");
+ // The repository scopes by community, so a foreign identifier is
+ // intentionally indistinguishable from an unknown identifier.
+ throw new Error('Property owner not found');
}
property.owner = member;
}
diff --git a/packages/ocom/application-services/src/contexts/property/property/create.ts b/packages/ocom/application-services/src/contexts/property/property/create.ts
index 94cc9ea9a..90f92a071 100644
--- a/packages/ocom/application-services/src/contexts/property/property/create.ts
+++ b/packages/ocom/application-services/src/contexts/property/property/create.ts
@@ -1,19 +1,60 @@
import type { Domain } from '@ocom/domain';
import type { DataSources } from '@ocom/persistence';
import { applyPropertyFields, type PropertyFieldsCommand } from './apply-property-fields.ts';
-import { ensureCommunityPropertiesViewable } from './ensure-property-viewable.ts';
+import { ensureCommunityPropertiesCreatable, ensureCommunityPropertiesManageable, isCommunityPropertiesManageable } from './ensure-property-viewable.ts';
export interface PropertyCreateCommand extends PropertyFieldsCommand {
propertyName: string;
communityId: string;
}
-export const create = (dataSources: DataSources) => {
+/**
+ * Request-scoped identity supplied only by the authenticated application
+ * services factory. It is deliberately not part of a GraphQL command.
+ */
+export interface PropertyRequestContext {
+ readonly currentMember: Domain.Contexts.Community.Member.MemberEntityReference | null;
+}
+
+const noCurrentMemberContext: PropertyRequestContext = {
+ currentMember: null,
+};
+
+export const create = (dataSources: DataSources, requestContext: PropertyRequestContext = noCurrentMemberContext) => {
return async (command: PropertyCreateCommand): Promise => {
// Authorize against the target community before any lookups, so an
// unauthorized caller cannot probe community existence or
// property-name availability from the distinct error responses.
- ensureCommunityPropertiesViewable(dataSources.passport, command.communityId);
+ ensureCommunityPropertiesCreatable(dataSources.passport, command.communityId);
+ const canManageProperties = isCommunityPropertiesManageable(dataSources.passport, command.communityId);
+
+ // Type is a manager-owned field. Reject it before checking the requested
+ // name so an own-property editor cannot use this mutation as a name
+ // availability oracle.
+ if (command.propertyType !== undefined && !canManageProperties) {
+ ensureCommunityPropertiesManageable(dataSources.passport, command.communityId);
+ }
+
+ let owner: Domain.Contexts.Community.Member.MemberEntityReference | null = null;
+ if (canManageProperties) {
+ if (command.ownerId !== undefined && command.ownerId !== null) {
+ owner = await dataSources.readonlyDataSource.Community.Member.MemberReadRepo.getByIdInCommunityWithRole(command.ownerId, command.communityId);
+ if (!owner) {
+ throw new Error('Property owner not found');
+ }
+ }
+ } else {
+ // A member's owner is never command data, including if it happens to
+ // equal their own id. Bind the verified canonical member instead.
+ if (command.ownerId !== undefined) {
+ throw new Error('Property owner cannot be specified');
+ }
+ const currentMember = requestContext.currentMember;
+ if (!currentMember || currentMember.community.id !== command.communityId) {
+ throw new Error('Unauthorized');
+ }
+ owner = currentMember;
+ }
let community: Domain.Contexts.Community.Community.CommunityEntityReference | undefined;
await dataSources.domainDataSource.Community.Community.CommunityUnitOfWork.withScopedTransaction(async (repo) => {
@@ -33,10 +74,12 @@ export const create = (dataSources: DataSources) => {
let propertyToReturn: Domain.Contexts.Property.Property.PropertyEntityReference | undefined;
await dataSources.domainDataSource.Property.Property.PropertyUnitOfWork.withScopedTransaction(async (repo) => {
- const newProperty = await repo.getNewInstance(command.propertyName, communityToUse);
- // The remaining fields are applied within the same transaction; the
- // aggregate setters enforce the manage-properties authorization.
- await applyPropertyFields(dataSources, newProperty, command);
+ const newProperty = await repo.getNewInstance(command.propertyName, communityToUse, owner);
+ // Owner was bound in the aggregate factory from either the trusted
+ // request context or a community-scoped manager lookup. It must not be
+ // reapplied from command data.
+ const { ownerId: _ownerId, ...remainingFields } = command;
+ await applyPropertyFields(dataSources, newProperty, remainingFields);
propertyToReturn = await repo.save(newProperty);
});
if (!propertyToReturn) {
diff --git a/packages/ocom/application-services/src/contexts/property/property/ensure-property-manageable.ts b/packages/ocom/application-services/src/contexts/property/property/ensure-property-manageable.ts
index 3f3f3b153..9d64cdb2b 100644
--- a/packages/ocom/application-services/src/contexts/property/property/ensure-property-manageable.ts
+++ b/packages/ocom/application-services/src/contexts/property/property/ensure-property-manageable.ts
@@ -1,6 +1,17 @@
const PROPERTY_NOT_FOUND_MESSAGE = 'Property not found';
-const hasErrorName = (error: unknown, name: string): boolean => typeof error === 'object' && error !== null && (error as Error).name === name;
+const hasErrorName = (error: unknown, name: string): boolean => error instanceof Error && error.name === name;
+
+export const assertPropertyManageableOrThrow = (property: TProperty): void => {
+ try {
+ property.assertCanManageProperties();
+ } catch (error) {
+ if (hasErrorName(error, 'PermissionError')) {
+ throw new Error(PROPERTY_NOT_FOUND_MESSAGE);
+ }
+ throw error;
+ }
+};
/**
* Loads a property for a manage-style mutation while collapsing "unknown id"
@@ -19,8 +30,27 @@ export const getManageablePropertyOrThrow = async (repo: { getById(id: string): Promise }, id: string): Promise => {
+ let property: TProperty;
try {
- property.assertCanManageProperties();
+ property = await repo.getById(id);
+ } catch (error) {
+ if (hasErrorName(error, 'NotFoundError')) {
+ throw new Error(PROPERTY_NOT_FOUND_MESSAGE);
+ }
+ throw error;
+ }
+ try {
+ property.assertCanEditProperties();
} catch (error) {
if (hasErrorName(error, 'PermissionError')) {
throw new Error(PROPERTY_NOT_FOUND_MESSAGE);
diff --git a/packages/ocom/application-services/src/contexts/property/property/ensure-property-viewable.ts b/packages/ocom/application-services/src/contexts/property/property/ensure-property-viewable.ts
index daa0e7898..ee6986e13 100644
--- a/packages/ocom/application-services/src/contexts/property/property/ensure-property-viewable.ts
+++ b/packages/ocom/application-services/src/contexts/property/property/ensure-property-viewable.ts
@@ -1,22 +1,25 @@
import type { Domain } from '@ocom/domain';
+type PropertyAuthorizationSubject = Domain.Contexts.Property.PropertyAuthorizationSubject;
+
+const forCommunity = (communityId: string): PropertyAuthorizationSubject => ({
+ community: { id: communityId },
+});
+
/**
* Non-throwing variant of {@link ensurePropertyViewable} for read paths that
* deny by omission instead of by error.
*/
-export const isPropertyViewable = (passport: Domain.Passport, property: Domain.Contexts.Property.Property.PropertyEntityReference): boolean => {
- return passport.property.forProperty(property).determineIf((permissions) => permissions.isSystemAccount || permissions.canManageProperties);
+export const isPropertyViewable = (passport: Domain.Passport, property: PropertyAuthorizationSubject): boolean => {
+ return passport.property.forProperty(property).determineIf((permissions) => permissions.isSystemAccount || permissions.canManageProperties || permissions.canEditOwnProperty);
};
/**
- * Admin-side property reads must be authorized by the request passport's
- * property visa (spec: application service operations enforce
- * `canManageProperties`). The passport is built from the request's current
- * member/community hints, so a manager of another community — or the same
- * user acting under a different community context — is denied here even if
- * they hold manage permissions elsewhere.
+ * Property reads are authorized by the request passport's property visa. An
+ * accepted own-property member can read every property in their selected
+ * community, while a manager or system account retains the same access.
*/
-export const ensurePropertyViewable = (passport: Domain.Passport, property: Domain.Contexts.Property.Property.PropertyEntityReference): void => {
+export const ensurePropertyViewable = (passport: Domain.Passport, property: PropertyAuthorizationSubject): void => {
if (!isPropertyViewable(passport, property)) {
throw new Error('Unauthorized');
}
@@ -25,11 +28,34 @@ export const ensurePropertyViewable = (passport: Domain.Passport, property: Doma
/**
* Authorizes a community-wide property read before any rows are fetched, so an
* unauthorized actor is rejected even when the community has no properties.
- * Property visas scope by the root's community (and never dereference other
- * root fields for the manage/system predicates), so a minimal community-scoped
- * root is sufficient to evaluate the same predicate the per-property check uses.
+ * Property visas scope by the root's community (and this read predicate never
+ * needs an owner), so a minimal community-scoped subject is sufficient.
*/
export const ensureCommunityPropertiesViewable = (passport: Domain.Passport, communityId: string): void => {
- const communityScopedRoot = { community: { id: communityId } } as Domain.Contexts.Property.Property.PropertyEntityReference;
- ensurePropertyViewable(passport, communityScopedRoot);
+ ensurePropertyViewable(passport, forCommunity(communityId));
+};
+
+export const isCommunityPropertiesManageable = (passport: Domain.Passport, communityId: string): boolean => {
+ return passport.property.forProperty(forCommunity(communityId)).determineIf((permissions) => permissions.isSystemAccount || permissions.canManageProperties);
+};
+
+/**
+ * Management-only collection operations (owner selection and manager-created
+ * ownership assignment) must not inherit the broader listing-read policy.
+ */
+export const ensureCommunityPropertiesManageable = (passport: Domain.Passport, communityId: string): void => {
+ if (!isCommunityPropertiesManageable(passport, communityId)) {
+ throw new Error('Unauthorized');
+ }
+};
+
+/**
+ * A property may be created by a manager/system account or by an accepted
+ * member with the narrowly scoped own-property capability. The latter must
+ * still be paired with a trusted current-member context by create.ts.
+ */
+export const ensureCommunityPropertiesCreatable = (passport: Domain.Passport, communityId: string): void => {
+ if (!passport.property.forProperty(forCommunity(communityId)).determineIf((permissions) => permissions.isSystemAccount || permissions.canManageProperties || permissions.canEditOwnProperty)) {
+ throw new Error('Unauthorized');
+ }
};
diff --git a/packages/ocom/application-services/src/contexts/property/property/index.ts b/packages/ocom/application-services/src/contexts/property/property/index.ts
index d92dca213..ae475e2aa 100644
--- a/packages/ocom/application-services/src/contexts/property/property/index.ts
+++ b/packages/ocom/application-services/src/contexts/property/property/index.ts
@@ -8,13 +8,14 @@ import type {
PropertyListingDetailFieldsCommand,
PropertyLocationFieldsCommand,
} from './apply-property-fields.ts';
-import { create, type PropertyCreateCommand } from './create.ts';
+import { create, type PropertyCreateCommand, type PropertyRequestContext } from './create.ts';
import { type PropertyQueryByCommunityIdCommand, queryByCommunityId } from './query-by-community-id.ts';
import { type PropertyQueryByIdCommand, queryById } from './query-by-id.ts';
import { type PropertyOwnerOption, type PropertyQueryOwnerOptionsByCommunityIdCommand, queryOwnerOptionsByCommunityId } from './query-owner-options-by-community-id.ts';
import { type PropertyRequestDeleteCommand, requestDelete } from './request-delete.ts';
import { type PropertyUpdateCommand, type PropertyUpdateListingDetailCommand, update } from './update.ts';
+export type { PropertyRequestContext } from './create.ts';
export type {
PropertyAdditionalAmenityCommand,
PropertyAddressFieldsCommand,
@@ -36,9 +37,9 @@ export interface PropertyApplicationService {
queryOwnerOptionsByCommunityId: (command: PropertyQueryOwnerOptionsByCommunityIdCommand) => Promise;
}
-export const Property = (dataSources: DataSources): PropertyApplicationService => {
+export const Property = (dataSources: DataSources, requestContext?: PropertyRequestContext): PropertyApplicationService => {
return {
- create: create(dataSources),
+ create: create(dataSources, requestContext),
update: update(dataSources),
requestDelete: requestDelete(dataSources),
queryById: queryById(dataSources),
diff --git a/packages/ocom/application-services/src/contexts/property/property/property.application-service.test.ts b/packages/ocom/application-services/src/contexts/property/property/property.application-service.test.ts
index 823d70998..00dc4970a 100644
--- a/packages/ocom/application-services/src/contexts/property/property/property.application-service.test.ts
+++ b/packages/ocom/application-services/src/contexts/property/property/property.application-service.test.ts
@@ -9,6 +9,34 @@ import { update } from './update.ts';
type DataSources = Parameters[0];
+interface PropertySetCalls {
+ propertyName?: unknown;
+ propertyType?: unknown;
+ listedForSale?: unknown;
+ listedForRent?: unknown;
+ listedForLease?: unknown;
+ listedInDirectory?: unknown;
+ tags?: unknown;
+ owner?: unknown;
+}
+
+interface ListingDetailSets extends Record {
+ price?: unknown;
+ bedrooms?: unknown;
+ bathrooms?: unknown;
+ squareFeet?: unknown;
+}
+
+interface BedroomAdd {
+ roomName?: unknown;
+ bedDescriptions?: unknown;
+}
+
+interface AmenityAdd {
+ category?: unknown;
+ amenities?: unknown;
+}
+
describe('property application services', () => {
let dataSources: DataSources;
let communityRepository: {
@@ -26,6 +54,7 @@ describe('property application services', () => {
};
let memberReadRepository: {
getByIdWithRole: ReturnType;
+ getByIdInCommunityWithRole: ReturnType;
getByCommunityId: ReturnType;
};
let propertyVisaPermissions: { canManageProperties: boolean; canEditOwnProperty: boolean; isEditingOwnProperty: boolean; isSystemAccount: boolean };
@@ -47,6 +76,7 @@ describe('property application services', () => {
};
memberReadRepository = {
getByIdWithRole: vi.fn(),
+ getByIdInCommunityWithRole: vi.fn(),
getByCommunityId: vi.fn(),
};
// Mirrors MemberPropertyVisa: the request passport evaluates the predicate
@@ -101,11 +131,11 @@ describe('property application services', () => {
});
function makePropertyAggregate() {
- const setCalls: Record = {};
- const listingDetailSets: Record = {};
+ const setCalls: PropertySetCalls = {};
+ const listingDetailSets: ListingDetailSets = {};
const addressSets: Record[] = [];
- const bedroomAdds: Record[] = [];
- const amenityAdds: Record[] = [];
+ const bedroomAdds: BedroomAdd[] = [];
+ const amenityAdds: AmenityAdd[] = [];
const removedBedrooms: unknown[] = [];
const removedAmenities: unknown[] = [];
const existingBedroom = { props: { id: 'bedroom-1', roomName: 'Old Room', bedDescriptions: ['Queen'] } };
@@ -137,14 +167,14 @@ describe('property application services', () => {
removedBedrooms.push(props);
}),
requestNewBedroom: vi.fn(() => {
- const added: Record = {};
+ const added: BedroomAdd = {};
bedroomAdds.push(added);
return {
set roomName(value: unknown) {
- added['roomName'] = value;
+ added.roomName = value;
},
set bedDescriptions(value: unknown) {
- added['bedDescriptions'] = value;
+ added.bedDescriptions = value;
},
};
}),
@@ -152,14 +182,14 @@ describe('property application services', () => {
removedAmenities.push(props);
}),
requestNewAdditionalAmenity: vi.fn(() => {
- const added: Record = {};
+ const added: AmenityAdd = {};
amenityAdds.push(added);
return {
set category(value: unknown) {
- added['category'] = value;
+ added.category = value;
},
set amenities(value: unknown) {
- added['amenities'] = value;
+ added.amenities = value;
},
};
}),
@@ -210,33 +240,34 @@ describe('property application services', () => {
existingAmenity,
currentAddress,
assertCanManageProperties: vi.fn(),
+ assertCanEditProperties: vi.fn(),
community: { id: 'community-1' },
get propertyName() {
return 'Current Property Name';
},
set propertyName(value: unknown) {
- setCalls['propertyName'] = value;
+ setCalls.propertyName = value;
},
set propertyType(value: unknown) {
- setCalls['propertyType'] = value;
+ setCalls.propertyType = value;
},
set listedForSale(value: unknown) {
- setCalls['listedForSale'] = value;
+ setCalls.listedForSale = value;
},
set listedForRent(value: unknown) {
- setCalls['listedForRent'] = value;
+ setCalls.listedForRent = value;
},
set listedForLease(value: unknown) {
- setCalls['listedForLease'] = value;
+ setCalls.listedForLease = value;
},
set listedInDirectory(value: unknown) {
- setCalls['listedInDirectory'] = value;
+ setCalls.listedInDirectory = value;
},
set tags(value: unknown) {
- setCalls['tags'] = value;
+ setCalls.tags = value;
},
set owner(value: unknown) {
- setCalls['owner'] = value;
+ setCalls.owner = value;
},
location: {
get address() {
@@ -262,7 +293,7 @@ describe('property application services', () => {
const result = await create(dataSources)({ propertyName: 'P1', communityId: 'community-1' });
expect(communityRepository.get).toHaveBeenCalledWith('community-1');
- expect(propertyRepository.getNewInstance).toHaveBeenCalledWith('P1', community);
+ expect(propertyRepository.getNewInstance).toHaveBeenCalledWith('P1', community, null);
expect(propertyRepository.save).toHaveBeenCalledWith(newProperty);
expect(result).toBe(newProperty);
});
@@ -281,7 +312,7 @@ describe('property application services', () => {
communityRepository.get.mockResolvedValue(community);
propertyRepository.getNewInstance.mockResolvedValue(property);
propertyRepository.save.mockResolvedValue(property);
- memberReadRepository.getByIdWithRole.mockResolvedValue(member);
+ memberReadRepository.getByIdInCommunityWithRole.mockResolvedValue(member);
const result = await create(dataSources)({
propertyName: 'Grand Pavilion',
@@ -295,17 +326,16 @@ describe('property application services', () => {
listingDetail: { price: 1250000, bathrooms: 3.5 },
});
- expect(propertyRepository.getNewInstance).toHaveBeenCalledWith('Grand Pavilion', community);
+ expect(propertyRepository.getNewInstance).toHaveBeenCalledWith('Grand Pavilion', community, member);
expect(property.setCalls).toEqual({
propertyType: 'house',
listedForSale: true,
listedInDirectory: true,
tags: ['waterfront', 'pool'],
- owner: member,
});
expect(property.addressSets[0]).toMatchObject({ streetNumber: '42', streetName: 'Shoreline Dr', municipality: 'Old Town' });
- expect((property.listingDetailSets['price'] as { valueOf(): number }).valueOf()).toBe(1250000);
- expect((property.listingDetailSets['bathrooms'] as { valueOf(): number }).valueOf()).toBe(3.5);
+ expect((property.listingDetailSets.price as { valueOf(): number }).valueOf()).toBe(1250000);
+ expect((property.listingDetailSets.bathrooms as { valueOf(): number }).valueOf()).toBe(3.5);
expect(propertyRepository.save).toHaveBeenCalledWith(property);
expect(result).toBe(property);
});
@@ -316,7 +346,7 @@ describe('property application services', () => {
communityRepository.get.mockResolvedValue(community);
propertyRepository.getNewInstance.mockResolvedValue(property);
propertyRepository.save.mockResolvedValue(property);
- memberReadRepository.getByIdWithRole.mockResolvedValue({ id: 'member-2', communityId: 'other-community' });
+ memberReadRepository.getByIdInCommunityWithRole.mockResolvedValue(null);
await expect(
create(dataSources)({
@@ -324,7 +354,7 @@ describe('property application services', () => {
communityId: 'community-1',
ownerId: 'member-2',
}),
- ).rejects.toThrow("Owner member does not belong to the property's community");
+ ).rejects.toThrow('Property owner not found');
expect(propertyRepository.save).not.toHaveBeenCalled();
});
@@ -371,6 +401,223 @@ describe('property application services', () => {
});
});
+ describe('property application services member authorization', () => {
+ let dataSources: DataSources;
+ let communityRepository: {
+ get: ReturnType;
+ };
+ let propertyRepository: {
+ getNewInstance: ReturnType;
+ getById: ReturnType;
+ save: ReturnType;
+ };
+ let propertyReadRepository: {
+ getById: ReturnType;
+ getByCommunityId: ReturnType;
+ isPropertyNameTaken: ReturnType;
+ };
+ let memberReadRepository: {
+ getByIdInCommunityWithRole: ReturnType;
+ getByCommunityId: ReturnType;
+ };
+ let propertyPermissions: {
+ canManageProperties: boolean;
+ canEditOwnProperty: boolean;
+ isEditingOwnProperty: boolean;
+ isSystemAccount: boolean;
+ };
+
+ const trustedMember = {
+ id: 'member-1',
+ communityId: 'community-1',
+ community: { id: 'community-1' },
+ } as unknown as Domain.Contexts.Community.Member.MemberEntityReference;
+
+ const makeProperty = () => {
+ const setCalls: PropertySetCalls = {};
+ return {
+ id: 'property-1',
+ setCalls,
+ community: { id: 'community-1' },
+ assertCanManageProperties: vi.fn(),
+ assertCanEditProperties: vi.fn(),
+ set listedInDirectory(value: boolean) {
+ setCalls.listedInDirectory = value;
+ },
+ set propertyName(value: string) {
+ setCalls.propertyName = value;
+ },
+ set propertyType(value: string | null) {
+ setCalls.propertyType = value;
+ },
+ set owner(value: unknown) {
+ setCalls.owner = value;
+ },
+ };
+ };
+
+ beforeEach(() => {
+ communityRepository = { get: vi.fn() };
+ propertyRepository = {
+ getNewInstance: vi.fn(),
+ getById: vi.fn(),
+ save: vi.fn(),
+ };
+ propertyReadRepository = {
+ getById: vi.fn(),
+ getByCommunityId: vi.fn(),
+ isPropertyNameTaken: vi.fn().mockResolvedValue(false),
+ };
+ memberReadRepository = {
+ getByIdInCommunityWithRole: vi.fn(),
+ getByCommunityId: vi.fn(),
+ };
+ propertyPermissions = {
+ canManageProperties: false,
+ canEditOwnProperty: true,
+ isEditingOwnProperty: true,
+ isSystemAccount: false,
+ };
+ dataSources = {
+ passport: {
+ property: {
+ forProperty: vi.fn(() => ({
+ determineIf: (predicate: (permissions: typeof propertyPermissions) => boolean) => predicate(propertyPermissions),
+ })),
+ },
+ },
+ domainDataSource: {
+ Community: {
+ Community: {
+ CommunityUnitOfWork: {
+ withScopedTransaction: vi.fn(async (callback: (repo: typeof communityRepository) => Promise) => callback(communityRepository)),
+ },
+ },
+ },
+ Property: {
+ Property: {
+ PropertyUnitOfWork: {
+ withScopedTransaction: vi.fn(async (callback: (repo: typeof propertyRepository) => Promise) => callback(propertyRepository)),
+ },
+ },
+ },
+ },
+ readonlyDataSource: {
+ Community: { Member: { MemberReadRepo: memberReadRepository } },
+ Property: { Property: { PropertyReadRepo: propertyReadRepository } },
+ },
+ } as unknown as DataSources;
+ });
+
+ it('allows a permissioned member to list all active properties in their current community before reading rows', async () => {
+ propertyReadRepository.getByCommunityId.mockResolvedValue([{ id: 'own-property' }, { id: 'foreign-property' }]);
+
+ await expect(queryByCommunityId(dataSources)({ communityId: 'community-1' })).resolves.toEqual([{ id: 'own-property' }, { id: 'foreign-property' }]);
+ expect(propertyReadRepository.getByCommunityId).toHaveBeenCalledWith('community-1');
+ });
+
+ it('binds the verified current member as owner when an own-property member creates a property', async () => {
+ const community = { id: 'community-1' };
+ const property = makeProperty();
+ communityRepository.get.mockResolvedValue(community);
+ propertyRepository.getNewInstance.mockResolvedValue(property);
+ propertyRepository.save.mockResolvedValue(property);
+
+ await expect(create(dataSources, { currentMember: trustedMember })({ propertyName: 'Maya Cottage', communityId: 'community-1' })).resolves.toBe(property);
+
+ expect(propertyRepository.getNewInstance).toHaveBeenCalledWith('Maya Cottage', community, trustedMember);
+ expect(propertyRepository.save).toHaveBeenCalledWith(property);
+ });
+
+ it('rejects every client ownerId for own-property member creation before owner lookup or field application', async () => {
+ await expect(
+ create(dataSources, { currentMember: trustedMember })({
+ propertyName: 'Spoofed Cottage',
+ communityId: 'community-1',
+ ownerId: 'member-1',
+ }),
+ ).rejects.toThrow('Property owner cannot be specified');
+
+ expect(memberReadRepository.getByIdInCommunityWithRole).not.toHaveBeenCalled();
+ expect(propertyRepository.getNewInstance).not.toHaveBeenCalled();
+ expect(propertyRepository.save).not.toHaveBeenCalled();
+ });
+
+ it('masks a foreign own-editor update as a missing property without applying fields', async () => {
+ const property = makeProperty();
+ const permissionError = Object.assign(new Error('not editable'), { name: 'PermissionError' });
+ property.assertCanEditProperties.mockImplementation(() => {
+ throw permissionError;
+ });
+ propertyRepository.getById.mockResolvedValue(property);
+
+ await expect(update(dataSources)({ id: 'foreign-property', listedInDirectory: true })).rejects.toThrow('Property not found');
+
+ expect(property.assertCanEditProperties).toHaveBeenCalledTimes(1);
+ expect(property.setCalls).toEqual({});
+ expect(propertyRepository.save).not.toHaveBeenCalled();
+ });
+
+ it('allows an own editor to update listing content through the editable-property loader', async () => {
+ const property = makeProperty();
+ propertyRepository.getById.mockResolvedValue(property);
+ propertyRepository.save.mockResolvedValue(property);
+
+ await expect(update(dataSources)({ id: 'property-1', listedInDirectory: true })).resolves.toBe(property);
+
+ expect(property.assertCanEditProperties).toHaveBeenCalledTimes(1);
+ expect(property.setCalls).toEqual({ listedInDirectory: true });
+ expect(propertyRepository.save).toHaveBeenCalledWith(property);
+ });
+
+ it('rejects a mixed own-editor update before saving any allowed field', async () => {
+ const property = makeProperty();
+ const permissionError = Object.assign(new Error('not manageable'), { name: 'PermissionError' });
+ property.assertCanManageProperties.mockImplementation(() => {
+ throw permissionError;
+ });
+ propertyRepository.getById.mockResolvedValue(property);
+
+ await expect(update(dataSources)({ id: 'property-1', propertyName: 'Renamed Cottage', listedInDirectory: true })).rejects.toThrow('Property not found');
+
+ expect(property.assertCanEditProperties).toHaveBeenCalledTimes(1);
+ expect(property.setCalls).toEqual({});
+ expect(propertyRepository.save).not.toHaveBeenCalled();
+ });
+
+ it('rejects an explicit null manager-only name before applying a listing update', async () => {
+ const property = makeProperty();
+ const permissionError = Object.assign(new Error('not manageable'), { name: 'PermissionError' });
+ property.assertCanManageProperties.mockImplementation(() => {
+ throw permissionError;
+ });
+ propertyRepository.getById.mockResolvedValue(property);
+
+ await expect(update(dataSources)({ id: 'property-1', propertyName: null, listedInDirectory: true })).rejects.toThrow('Property not found');
+
+ expect(property.assertCanEditProperties).toHaveBeenCalledTimes(1);
+ expect(property.setCalls).toEqual({});
+ expect(propertyRepository.save).not.toHaveBeenCalled();
+ });
+
+ it('keeps owner options manager-only even when a member can list and read properties', async () => {
+ await expect(queryOwnerOptionsByCommunityId(dataSources)({ communityId: 'community-1' })).rejects.toThrow('Unauthorized');
+ expect(memberReadRepository.getByCommunityId).not.toHaveBeenCalled();
+ });
+
+ it('uses a community-scoped owner lookup and masks a foreign owner id for a manager', async () => {
+ propertyPermissions.canManageProperties = true;
+ const property = makeProperty();
+ propertyRepository.getById.mockResolvedValue(property);
+ memberReadRepository.getByIdInCommunityWithRole.mockResolvedValue(null);
+
+ await expect(update(dataSources)({ id: 'property-1', ownerId: 'foreign-member' })).rejects.toThrow('Property owner not found');
+
+ expect(memberReadRepository.getByIdInCommunityWithRole).toHaveBeenCalledWith('foreign-member', 'community-1');
+ expect(propertyRepository.save).not.toHaveBeenCalled();
+ });
+ });
+
describe('update', () => {
it('applies only the provided fields', async () => {
const property = makePropertyAggregate();
@@ -400,7 +647,7 @@ describe('property application services', () => {
const property = makePropertyAggregate();
const permissionError = new Error('You do not have permission to manage properties');
permissionError.name = 'PermissionError';
- property.assertCanManageProperties.mockImplementation(() => {
+ property.assertCanEditProperties.mockImplementation(() => {
throw permissionError;
});
propertyRepository.getById.mockResolvedValue(property);
@@ -433,10 +680,10 @@ describe('property application services', () => {
});
expect(property.setCalls).toEqual({ propertyName: 'New Name' });
- expect(property.listingDetailSets['bedrooms']).toBeInstanceOf(Domain.Contexts.Property.Property.ListingDetailValueObjects.Bedrooms);
- expect((property.listingDetailSets['bedrooms'] as { valueOf(): number }).valueOf()).toBe(3);
- expect((property.listingDetailSets['bathrooms'] as { valueOf(): number }).valueOf()).toBe(2.5);
- expect((property.listingDetailSets['squareFeet'] as { valueOf(): number }).valueOf()).toBe(1750);
+ expect(property.listingDetailSets.bedrooms).toBeInstanceOf(Domain.Contexts.Property.Property.ListingDetailValueObjects.Bedrooms);
+ expect((property.listingDetailSets.bedrooms as { valueOf(): number }).valueOf()).toBe(3);
+ expect((property.listingDetailSets.bathrooms as { valueOf(): number }).valueOf()).toBe(2.5);
+ expect((property.listingDetailSets.squareFeet as { valueOf(): number }).valueOf()).toBe(1750);
});
it('applies explicit nulls as value objects to clear listing detail fields', async () => {
@@ -449,9 +696,9 @@ describe('property application services', () => {
listingDetail: { bedrooms: null, bathrooms: null, squareFeet: null },
});
- expect((property.listingDetailSets['bedrooms'] as { valueOf(): number | null }).valueOf()).toBeNull();
- expect((property.listingDetailSets['bathrooms'] as { valueOf(): number | null }).valueOf()).toBeNull();
- expect((property.listingDetailSets['squareFeet'] as { valueOf(): number | null }).valueOf()).toBeNull();
+ expect((property.listingDetailSets.bedrooms as { valueOf(): number | null }).valueOf()).toBeNull();
+ expect((property.listingDetailSets.bathrooms as { valueOf(): number | null }).valueOf()).toBeNull();
+ expect((property.listingDetailSets.squareFeet as { valueOf(): number | null }).valueOf()).toBeNull();
expect(propertyRepository.save).toHaveBeenCalledWith(property);
});
@@ -543,11 +790,11 @@ describe('property application services', () => {
expect(property.removedBedrooms).toEqual([property.existingBedroom.props]);
expect(property.removedAmenities).toEqual([property.existingAmenity.props]);
expect(property.bedroomAdds).toHaveLength(1);
- expect((property.bedroomAdds[0]?.['roomName'] as { valueOf(): string }).valueOf()).toBe('Primary Suite');
- expect((property.bedroomAdds[0]?.['bedDescriptions'] as { valueOf(): string[] }).valueOf()).toEqual(['King', 'Crib']);
+ expect((property.bedroomAdds[0]?.roomName as { valueOf(): string }).valueOf()).toBe('Primary Suite');
+ expect((property.bedroomAdds[0]?.bedDescriptions as { valueOf(): string[] }).valueOf()).toEqual(['King', 'Crib']);
expect(property.amenityAdds).toHaveLength(1);
- expect((property.amenityAdds[0]?.['category'] as { valueOf(): string }).valueOf()).toBe('Outdoor');
- expect((property.amenityAdds[0]?.['amenities'] as { valueOf(): string[] }).valueOf()).toEqual(['Fire Pit', 'BBQ']);
+ expect((property.amenityAdds[0]?.category as { valueOf(): string }).valueOf()).toBe('Outdoor');
+ expect((property.amenityAdds[0]?.amenities as { valueOf(): string[] }).valueOf()).toEqual(['Fire Pit', 'BBQ']);
expect(propertyRepository.save).toHaveBeenCalledWith(property);
});
@@ -556,11 +803,11 @@ describe('property application services', () => {
const member = { id: 'member-1', communityId: 'community-1' };
propertyRepository.getById.mockResolvedValue(property);
propertyRepository.save.mockResolvedValue(property);
- memberReadRepository.getByIdWithRole.mockResolvedValue(member);
+ memberReadRepository.getByIdInCommunityWithRole.mockResolvedValue(member);
await update(dataSources)({ id: 'property-1', ownerId: 'member-1' });
- expect(memberReadRepository.getByIdWithRole).toHaveBeenCalledWith('member-1');
+ expect(memberReadRepository.getByIdInCommunityWithRole).toHaveBeenCalledWith('member-1', 'community-1');
expect(property.setCalls).toEqual({ owner: member });
expect(propertyRepository.save).toHaveBeenCalledWith(property);
});
@@ -572,7 +819,7 @@ describe('property application services', () => {
await update(dataSources)({ id: 'property-1', ownerId: null });
- expect(memberReadRepository.getByIdWithRole).not.toHaveBeenCalled();
+ expect(memberReadRepository.getByIdInCommunityWithRole).not.toHaveBeenCalled();
expect(property.setCalls).toEqual({ owner: null });
expect(propertyRepository.save).toHaveBeenCalledWith(property);
});
@@ -581,9 +828,9 @@ describe('property application services', () => {
const property = makePropertyAggregate();
propertyRepository.getById.mockResolvedValue(property);
propertyRepository.save.mockResolvedValue(property);
- memberReadRepository.getByIdWithRole.mockResolvedValue({ id: 'member-2', communityId: 'other-community' });
+ memberReadRepository.getByIdInCommunityWithRole.mockResolvedValue(null);
- await expect(update(dataSources)({ id: 'property-1', ownerId: 'member-2' })).rejects.toThrow("Owner member does not belong to the property's community");
+ await expect(update(dataSources)({ id: 'property-1', ownerId: 'member-2' })).rejects.toThrow('Property owner not found');
expect(property.setCalls).toEqual({});
expect(propertyRepository.save).not.toHaveBeenCalled();
@@ -593,9 +840,9 @@ describe('property application services', () => {
const property = makePropertyAggregate();
propertyRepository.getById.mockResolvedValue(property);
propertyRepository.save.mockResolvedValue(property);
- memberReadRepository.getByIdWithRole.mockResolvedValue(null);
+ memberReadRepository.getByIdInCommunityWithRole.mockResolvedValue(null);
- await expect(update(dataSources)({ id: 'property-1', ownerId: 'missing-member' })).rejects.toThrow('Owner member with id missing-member not found');
+ await expect(update(dataSources)({ id: 'property-1', ownerId: 'missing-member' })).rejects.toThrow('Property owner not found');
expect(propertyRepository.save).not.toHaveBeenCalled();
});
@@ -612,6 +859,7 @@ describe('property application services', () => {
it('propagates domain errors from the aggregate', async () => {
propertyRepository.getById.mockResolvedValue({
+ assertCanEditProperties: vi.fn(),
assertCanManageProperties: vi.fn(),
community: { id: 'community-1' },
get propertyName() {
diff --git a/packages/ocom/application-services/src/contexts/property/property/query-owner-options-by-community-id.ts b/packages/ocom/application-services/src/contexts/property/property/query-owner-options-by-community-id.ts
index efc001912..3b5334ef2 100644
--- a/packages/ocom/application-services/src/contexts/property/property/query-owner-options-by-community-id.ts
+++ b/packages/ocom/application-services/src/contexts/property/property/query-owner-options-by-community-id.ts
@@ -1,5 +1,5 @@
import type { DataSources } from '@ocom/persistence';
-import { ensureCommunityPropertiesViewable } from './ensure-property-viewable.ts';
+import { ensureCommunityPropertiesManageable } from './ensure-property-viewable.ts';
export interface PropertyQueryOwnerOptionsByCommunityIdCommand {
communityId: string;
@@ -24,7 +24,7 @@ export interface PropertyOwnerOption {
*/
export const queryOwnerOptionsByCommunityId = (dataSources: DataSources) => {
return async (command: PropertyQueryOwnerOptionsByCommunityIdCommand): Promise => {
- ensureCommunityPropertiesViewable(dataSources.passport, command.communityId);
+ ensureCommunityPropertiesManageable(dataSources.passport, command.communityId);
const members = await dataSources.readonlyDataSource.Community.Member.MemberReadRepo.getByCommunityId(command.communityId);
return members.map((member) => ({
id: member.id,
diff --git a/packages/ocom/application-services/src/contexts/property/property/update.ts b/packages/ocom/application-services/src/contexts/property/property/update.ts
index dbc7d9802..35d9644e0 100644
--- a/packages/ocom/application-services/src/contexts/property/property/update.ts
+++ b/packages/ocom/application-services/src/contexts/property/property/update.ts
@@ -1,20 +1,28 @@
import type { Domain } from '@ocom/domain';
import type { DataSources } from '@ocom/persistence';
import { applyPropertyFields, type PropertyFieldsCommand, type PropertyListingDetailFieldsCommand } from './apply-property-fields.ts';
-import { getManageablePropertyOrThrow } from './ensure-property-manageable.ts';
+import { assertPropertyManageableOrThrow, getEditablePropertyOrThrow } from './ensure-property-manageable.ts';
export type PropertyUpdateListingDetailCommand = PropertyListingDetailFieldsCommand;
export interface PropertyUpdateCommand extends PropertyFieldsCommand {
id: string;
- propertyName?: string;
+ propertyName?: string | null;
}
export const update = (dataSources: DataSources) => {
return async (command: PropertyUpdateCommand): Promise => {
let propertyToReturn: Domain.Contexts.Property.Property.PropertyEntityReference | undefined;
await dataSources.domainDataSource.Property.Property.PropertyUnitOfWork.withScopedTransaction(async (repo) => {
- const property = await getManageablePropertyOrThrow(repo, command.id);
+ const property = await getEditablePropertyOrThrow(repo, command.id);
+
+ // These fields are not listing content and must be authorized before
+ // applying any editable field. This keeps a mixed member request
+ // atomic even though individual aggregate setters authorize
+ // themselves too.
+ if (command.propertyName !== undefined || command.propertyType !== undefined || command.ownerId !== undefined) {
+ assertPropertyManageableOrThrow(property);
+ }
if (command.propertyName !== undefined && command.propertyName !== null) {
// Friendly pre-check on rename only (exact string compare, matching the
diff --git a/packages/ocom/application-services/src/index.test.ts b/packages/ocom/application-services/src/index.test.ts
index d39505e78..7a272e0bb 100644
--- a/packages/ocom/application-services/src/index.test.ts
+++ b/packages/ocom/application-services/src/index.test.ts
@@ -8,21 +8,28 @@ const memberOfCommunityA = {
id: 'member-1',
accounts: [{ user: { id: 'user-1' } }],
community: { id: 'community-a' },
+ role: { id: 'role-1' },
};
-const foreignMember = {
- id: 'member-2',
- accounts: [{ user: { id: 'someone-else' } }],
- community: { id: 'community-a' },
-};
-
-function buildContext(overrides?: { member?: unknown; community?: unknown }) {
+function buildContext(overrides?: { member?: unknown }) {
+ const currentMemberLookup = vi.fn((_endUserId: string, communityId: string) => {
+ if (overrides?.member !== undefined) {
+ return overrides.member;
+ }
+ return communityId === 'community-a' ? memberOfCommunityA : null;
+ });
+ const hintedMemberLookup = vi.fn().mockResolvedValue(memberOfCommunityA);
const readonlyDataSource = {
User: {
EndUser: { EndUserReadRepo: { getByExternalId: vi.fn().mockResolvedValue(endUser) } },
},
Community: {
- Member: { MemberReadRepo: { getByIdWithCommunityAndRoleAndUser: vi.fn().mockResolvedValue(overrides?.member ?? memberOfCommunityA) } },
- Community: { CommunityReadRepo: { getById: vi.fn().mockResolvedValue(overrides?.community ?? { id: 'community-a' }) } },
+ Member: {
+ MemberReadRepo: {
+ getByEndUserIdAndCommunityIdWithRole: currentMemberLookup,
+ getByIdWithCommunityAndRoleAndUser: hintedMemberLookup,
+ },
+ },
+ Community: { CommunityReadRepo: { getById: vi.fn().mockResolvedValue({ id: 'community-a' }) } },
},
};
const withPassport = vi.fn().mockReturnValue({});
@@ -40,22 +47,23 @@ function buildContext(overrides?: { member?: unknown; community?: unknown }) {
blobStorageService: {},
queueStorageService: {},
} as unknown as ApiContextSpec;
- return { context, withPassport };
+ return { context, withPassport, currentMemberLookup, hintedMemberLookup };
}
describe('buildApplicationServicesFactory forRequest principal hints', () => {
- it('falls back to the guest passport when the member does not belong to the hinted community', async () => {
- const { context, withPassport } = buildContext({ community: { id: 'community-b' } });
+ it('falls back to the guest passport when the verified user has no member in the requested community', async () => {
+ const { context, withPassport, currentMemberLookup } = buildContext();
const factory = buildApplicationServicesFactory(context);
await expect(factory.forRequest('Bearer token', { memberId: 'member-1', communityId: 'community-b' })).resolves.toBeDefined();
+ expect(currentMemberLookup).toHaveBeenCalledWith('user-1', 'community-b');
expect(withPassport).toHaveBeenCalledTimes(1);
expect(withPassport.mock.calls[0]?.[0]).toStrictEqual(Domain.PassportFactory.forGuest());
});
- it('falls back to the guest passport when the hinted member belongs to another user', async () => {
- const { context, withPassport } = buildContext({ member: foreignMember });
+ it('falls back to the guest passport when the verified current-member lookup does not return a member', async () => {
+ const { context, withPassport } = buildContext({ member: null });
const factory = buildApplicationServicesFactory(context);
await expect(factory.forRequest('Bearer token', { memberId: 'member-2', communityId: 'community-a' })).resolves.toBeDefined();
@@ -63,12 +71,54 @@ describe('buildApplicationServicesFactory forRequest principal hints', () => {
expect(withPassport.mock.calls[0]?.[0]).toStrictEqual(Domain.PassportFactory.forGuest());
});
- it('builds a member passport when the hinted member and community match', async () => {
- const { context, withPassport } = buildContext();
+ it('fails closed without throwing when the verified current member has no populated role', async () => {
+ const rolelessMember = Object.defineProperty(
+ {
+ ...memberOfCommunityA,
+ role: undefined,
+ },
+ 'role',
+ {
+ get() {
+ throw new Error('role is not populated');
+ },
+ },
+ );
+ const { context, withPassport } = buildContext({ member: rolelessMember });
+ const factory = buildApplicationServicesFactory(context);
+
+ await expect(factory.forRequest('******', { memberId: 'member-1', communityId: 'community-a' })).resolves.toBeDefined();
+
+ expect(withPassport.mock.calls[0]?.[0]).toStrictEqual(Domain.PassportFactory.forGuest());
+ });
+
+ it('propagates an unexpected current-member role resolution failure', async () => {
+ const unavailableRoleMember = Object.defineProperty(
+ {
+ ...memberOfCommunityA,
+ role: undefined,
+ },
+ 'role',
+ {
+ get() {
+ throw new Error('role service unavailable');
+ },
+ },
+ );
+ const { context } = buildContext({ member: unavailableRoleMember });
+ const factory = buildApplicationServicesFactory(context);
+
+ await expect(factory.forRequest('******', { memberId: 'member-1', communityId: 'community-a' })).rejects.toThrow('role service unavailable');
+ });
+
+ it('uses the verified-user and community lookup rather than the untrusted member-id hint', async () => {
+ const { context, withPassport, currentMemberLookup, hintedMemberLookup } = buildContext();
const factory = buildApplicationServicesFactory(context);
await factory.forRequest('Bearer token', { memberId: 'member-1', communityId: 'community-a' });
+ expect(currentMemberLookup).toHaveBeenCalledWith('user-1', 'community-a');
+ expect(hintedMemberLookup).not.toHaveBeenCalled();
expect(withPassport.mock.calls[0]?.[0]).not.toStrictEqual(Domain.PassportFactory.forGuest());
});
diff --git a/packages/ocom/application-services/src/index.ts b/packages/ocom/application-services/src/index.ts
index d227b16f5..c3cbd6c03 100644
--- a/packages/ocom/application-services/src/index.ts
+++ b/packages/ocom/application-services/src/index.ts
@@ -55,31 +55,47 @@ export interface AppServicesHost {
export type ApplicationServicesFactory = AppServicesHost;
+const isUnpopulatedMemberRoleError = (error: unknown): boolean => error instanceof Error && (error.message === 'role is not populated' || error.message === 'role is not populated or is not of the correct type');
+
+/**
+ * Members without an assigned role are valid route-denial subjects. Domain
+ * member references expose `role` as a required populated reference, so an
+ * absent persisted role throws when its getter is read. Treat that expected
+ * absence as ineligible rather than failing request-context construction.
+ */
+const hasResolvedMemberRole = (member: Domain.Contexts.Community.Member.MemberEntityReference | null): boolean => {
+ if (!member) {
+ return false;
+ }
+ try {
+ return Boolean(member.role);
+ } catch (error) {
+ if (isUnpopulatedMemberRoleError(error)) {
+ return false;
+ }
+ throw error;
+ }
+};
+
export const buildApplicationServicesFactory = (context: ApiContextSpec): ApplicationServicesFactory => {
const forRequest = async (rawAuthHeader?: string, hints?: PrincipalHints): Promise => {
const accessToken = rawAuthHeader?.replace(/^Bearer\s+/i, '').trim();
const tokenValidationResult = accessToken ? await context.tokenValidationService.verifyJwt(accessToken) : null;
let passport = Domain.PassportFactory.forGuest();
+ let currentMember: Domain.Contexts.Community.Member.MemberEntityReference | null = null;
if (tokenValidationResult !== null) {
const { verifiedJwt, openIdConfigKey } = tokenValidationResult;
const { readonlyDataSource } = context.dataSourcesFactory.withSystemPassport();
if (openIdConfigKey === 'AccountPortal') {
const endUser = await readonlyDataSource.User.EndUser.EndUserReadRepo.getByExternalId(verifiedJwt.sub);
- const member = hints?.memberId ? await readonlyDataSource.Community.Member.MemberReadRepo.getByIdWithCommunityAndRoleAndUser(hints?.memberId) : null;
- const community = hints?.communityId ? await readonlyDataSource.Community.Community.CommunityReadRepo.getById(hints?.communityId) : null;
+ const member = endUser && hints?.communityId ? await readonlyDataSource.Community.Member.MemberReadRepo.getByEndUserIdAndCommunityIdWithRole(endUser.id, hints.communityId) : null;
- if (endUser && member && community) {
- // Build a member passport only for coherent principal hints: the
- // member must belong to both the authenticated user and the hinted
- // community. Incoherent hints (e.g. a member id paired with another
- // community's id in the route) fail closed to the guest passport,
- // matching the fallback when a hint lookup finds nothing. Any other
- // passport-construction failure propagates.
- const memberBelongsToUser = member.accounts.some((account) => account.user.id === endUser.id);
- const memberBelongsToCommunity = member.community.id === community.id;
- if (memberBelongsToUser && memberBelongsToCommunity) {
- passport = Domain.PassportFactory.forMember(endUser, member, community);
- }
+ // A selected community is not an authentication claim, but it can
+ // scope the canonical membership lookup. The member ID header is
+ // intentionally never consulted as actor identity.
+ if (endUser && member && hasResolvedMemberRole(member)) {
+ passport = Domain.PassportFactory.forMember(endUser, member, member.community);
+ currentMember = member;
}
} else if (openIdConfigKey === 'StaffPortal') {
const staffUser = await readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(verifiedJwt.sub);
@@ -95,7 +111,7 @@ export const buildApplicationServicesFactory = (context: ApiContextSpec): Applic
return {
Community: Community(dataSources, blobStorageService, queueStorageService),
- Property: Property(dataSources),
+ Property: Property(dataSources, { currentMember }),
Service: Service(dataSources),
User: User(dataSources),
get verifiedUser(): VerifiedUser | null {
diff --git a/packages/ocom/domain/src/domain/contexts/property/index.ts b/packages/ocom/domain/src/domain/contexts/property/index.ts
index 9955dd9c9..2a5fb71c1 100644
--- a/packages/ocom/domain/src/domain/contexts/property/index.ts
+++ b/packages/ocom/domain/src/domain/contexts/property/index.ts
@@ -1,2 +1,2 @@
export * as Property from './property/index.ts';
-export type { PropertyPassport } from './property.passport.ts';
+export type { PropertyAuthorizationSubject, PropertyPassport } from './property.passport.ts';
diff --git a/packages/ocom/domain/src/domain/contexts/property/property.passport.ts b/packages/ocom/domain/src/domain/contexts/property/property.passport.ts
index cd61c6206..d5c55994c 100644
--- a/packages/ocom/domain/src/domain/contexts/property/property.passport.ts
+++ b/packages/ocom/domain/src/domain/contexts/property/property.passport.ts
@@ -1,6 +1,20 @@
-import type { PropertyEntityReference } from './property/property.aggregate.ts';
import type { PropertyVisa } from './property.visa.ts';
+/**
+ * The minimum immutable property state required to evaluate property access.
+ * Keeping this narrower than the aggregate lets collection operations
+ * authorize a community before querying rows without manufacturing a fake
+ * Property aggregate.
+ */
+export interface PropertyAuthorizationSubject {
+ readonly community: {
+ readonly id: string;
+ };
+ readonly owner?: {
+ readonly id: string;
+ } | null;
+}
+
export interface PropertyPassport {
- forProperty(root: PropertyEntityReference): PropertyVisa;
+ forProperty(root: PropertyAuthorizationSubject): PropertyVisa;
}
diff --git a/packages/ocom/domain/src/domain/contexts/property/property/features/property.aggregate.feature b/packages/ocom/domain/src/domain/contexts/property/property/features/property.aggregate.feature
index 528d5f221..a9a61e758 100644
--- a/packages/ocom/domain/src/domain/contexts/property/property/features/property.aggregate.feature
+++ b/packages/ocom/domain/src/domain/contexts/property/property/features/property.aggregate.feature
@@ -183,8 +183,8 @@ Feature: Property
Scenario: Setting the hash with edit own property permission
Given a Property aggregate with edit own property permission and is editing own property
- When I set the hash to "new-hash-value"
- Then the property's hash should be "new-hash-value"
+ When I try to set the hash to "new-hash-value"
+ Then a PermissionError should be thrown
Scenario: Setting lastIndexed with permission to manage properties
Given a Property aggregate with permission to manage properties
@@ -198,8 +198,8 @@ Feature: Property
Scenario: Setting lastIndexed with edit own property permission
Given a Property aggregate with edit own property permission and is editing own property
- When I set lastIndexed to a specific date
- Then the property's lastIndexed should be updated
+ When I try to set lastIndexed to a specific date
+ Then a PermissionError should be thrown
Scenario: Setting updateIndexFailedDate with permission to manage properties
Given a Property aggregate with permission to manage properties
@@ -213,8 +213,8 @@ Feature: Property
Scenario: Setting updateIndexFailedDate with edit own property permission
Given a Property aggregate with edit own property permission and is editing own property
- When I set updateIndexFailedDate to a specific date
- Then the property's updateIndexFailedDate should be updated
+ When I try to set updateIndexFailedDate to a specific date
+ Then a PermissionError should be thrown
Scenario: Getting listingDetail
Given a Property aggregate
diff --git a/packages/ocom/domain/src/domain/contexts/property/property/property.aggregate.test.ts b/packages/ocom/domain/src/domain/contexts/property/property/property.aggregate.test.ts
index ad36dcbd6..de421fe05 100644
--- a/packages/ocom/domain/src/domain/contexts/property/property/property.aggregate.test.ts
+++ b/packages/ocom/domain/src/domain/contexts/property/property/property.aggregate.test.ts
@@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url';
import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber';
import { PermissionError } from '@cellix/domain-seedwork/domain-entity';
import type { PropArray } from '@cellix/domain-seedwork/prop-array';
-import { expect, vi } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import type { CommunityEntityReference, CommunityProps } from '../../community/community/community.ts';
import type { MemberEntityReference } from '../../community/member/member.ts';
import type { MemberAccountEntityReference } from '../../community/member/member-account.ts';
@@ -193,7 +193,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => {
Scenario('Creating a new property instance', ({ When, Then, And }) => {
When('I create a new Property aggregate using getNewInstance with propertyName "New Property", and a CommunityEntityReference', () => {
- newProperty = Property.getNewInstance(makeBaseProps(), 'New Property', communityRef, passport);
+ newProperty = Property.getNewInstance(makeBaseProps(), 'New Property', communityRef, null, passport);
});
Then('the property\'s propertyName should be "New Property"', () => {
expect(newProperty.propertyName).toBe('New Property');
@@ -215,6 +215,77 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => {
});
});
+ describe('Property member ownership policy', () => {
+ it('binds the trusted member owner before applying own-editor creation defaults', () => {
+ const community = makeCommunityEntityReference('community-1');
+ const trustedOwner = makeMemberEntityReference('member-1');
+ const passport = makePassport({
+ canManageProperties: false,
+ canEditOwnProperty: true,
+ isEditingOwnProperty: true,
+ });
+
+ const property = Property.getNewInstance(makeBaseProps({ owner: null }), 'Member Cottage', community, trustedOwner, passport);
+
+ expect(property.owner?.id).toBe('member-1');
+ expect(property.listedForSale).toBe(false);
+ expect(property.listedForRent).toBe(false);
+ expect(property.listedForLease).toBe(false);
+ expect(property.listedInDirectory).toBe(false);
+ });
+
+ it('uses the bound owner for own-editor creation when persistence stores the owner as an unresolved reference', () => {
+ const community = makeCommunityEntityReference('community-1');
+ const trustedOwner = makeMemberEntityReference('member-1');
+ const setOwnerRef = vi.fn();
+ const props = makeBaseProps({
+ owner: null,
+ setOwnerRef,
+ });
+ Object.defineProperty(props, 'owner', {
+ get: () => {
+ throw new Error('owner relation is unresolved');
+ },
+ });
+ const passport = makePassport({
+ canManageProperties: false,
+ canEditOwnProperty: true,
+ isEditingOwnProperty: true,
+ });
+
+ const property = Property.getNewInstance(props, 'Member Cottage', community, trustedOwner, passport);
+
+ expect(property.owner).toBe(trustedOwner);
+ expect(setOwnerRef).toHaveBeenCalledWith(trustedOwner);
+ });
+
+ it('rejects own-property creation when the initial owner is not the actor', () => {
+ const community = makeCommunityEntityReference('community-1');
+ const foreignOwner = makeMemberEntityReference('member-2');
+ const passport = makePassport({
+ canManageProperties: false,
+ canEditOwnProperty: true,
+ isEditingOwnProperty: false,
+ });
+
+ expect(() => Property.getNewInstance(makeBaseProps({ owner: null }), 'Spoofed Cottage', community, foreignOwner, passport)).toThrow('Cannot create new property');
+ });
+
+ it('rejects assigning an owner from another community before changing the owner reference', () => {
+ const property = new Property(makeBaseProps({ owner: null }), makePassport({ canManageProperties: true }));
+ const foreignOwner = {
+ ...makeMemberEntityReference('member-2'),
+ communityId: 'community-2',
+ community: makeCommunityEntityReference('community-2'),
+ } as MemberEntityReference;
+
+ expect(() => {
+ property.owner = foreignOwner;
+ }).toThrow("Property owner must belong to the property's community");
+ expect(property.owner).toBeNull();
+ });
+ });
+
Scenario('Changing the propertyName with permission to manage properties', ({ Given, When, Then }) => {
Given('a Property aggregate with permission to manage properties', () => {
passport = makePassport({ canManageProperties: true });
@@ -752,6 +823,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => {
});
Scenario('Setting the hash with edit own property permission', ({ Given, When, Then }) => {
+ let setHashWithEditOwnPropertyPermission: () => void;
Given('a Property aggregate with edit own property permission and is editing own property', () => {
passport = makePassport({
canManageProperties: false,
@@ -760,11 +832,14 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => {
});
property = new Property(makeBaseProps(), passport);
});
- When('I set the hash to "new-hash-value"', () => {
- property.hash = 'new-hash-value';
+ When('I try to set the hash to "new-hash-value"', () => {
+ setHashWithEditOwnPropertyPermission = () => {
+ property.hash = 'new-hash-value';
+ };
});
- Then('the property\'s hash should be "new-hash-value"', () => {
- expect(property.hash).toBe('new-hash-value');
+ Then('a PermissionError should be thrown', () => {
+ expect(setHashWithEditOwnPropertyPermission).toThrow(PermissionError);
+ expect(setHashWithEditOwnPropertyPermission).toThrow('You do not have permission to update the index hash for this property');
});
});
@@ -805,6 +880,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => {
});
Scenario('Setting lastIndexed with edit own property permission', ({ Given, When, Then }) => {
+ let setLastIndexedWithEditOwnPropertyPermission: () => void;
Given('a Property aggregate with edit own property permission and is editing own property', () => {
passport = makePassport({
canManageProperties: false,
@@ -813,12 +889,15 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => {
});
property = new Property(makeBaseProps(), passport);
});
- When('I set lastIndexed to a specific date', () => {
- const testDate = new Date('2024-01-01T00:00:00Z');
- property.lastIndexed = testDate;
+ When('I try to set lastIndexed to a specific date', () => {
+ setLastIndexedWithEditOwnPropertyPermission = () => {
+ const testDate = new Date('2024-01-01T00:00:00Z');
+ property.lastIndexed = testDate;
+ };
});
- Then("the property's lastIndexed should be updated", () => {
- expect(property.lastIndexed).toEqual(new Date('2024-01-01T00:00:00Z'));
+ Then('a PermissionError should be thrown', () => {
+ expect(setLastIndexedWithEditOwnPropertyPermission).toThrow(PermissionError);
+ expect(setLastIndexedWithEditOwnPropertyPermission).toThrow('You do not have permission to update the index timestamp for this property');
});
});
@@ -859,6 +938,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => {
});
Scenario('Setting updateIndexFailedDate with edit own property permission', ({ Given, When, Then }) => {
+ let setUpdateIndexFailedDateWithEditOwnPropertyPermission: () => void;
Given('a Property aggregate with edit own property permission and is editing own property', () => {
passport = makePassport({
canManageProperties: false,
@@ -867,12 +947,15 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => {
});
property = new Property(makeBaseProps(), passport);
});
- When('I set updateIndexFailedDate to a specific date', () => {
- const testDate = new Date('2024-01-01T00:00:00Z');
- property.updateIndexFailedDate = testDate;
+ When('I try to set updateIndexFailedDate to a specific date', () => {
+ setUpdateIndexFailedDateWithEditOwnPropertyPermission = () => {
+ const testDate = new Date('2024-01-01T00:00:00Z');
+ property.updateIndexFailedDate = testDate;
+ };
});
- Then("the property's updateIndexFailedDate should be updated", () => {
- expect(property.updateIndexFailedDate).toEqual(new Date('2024-01-01T00:00:00Z'));
+ Then('a PermissionError should be thrown', () => {
+ expect(setUpdateIndexFailedDateWithEditOwnPropertyPermission).toThrow(PermissionError);
+ expect(setUpdateIndexFailedDateWithEditOwnPropertyPermission).toThrow('You do not have permission to update the failed index timestamp for this property');
});
});
diff --git a/packages/ocom/domain/src/domain/contexts/property/property/property.aggregate.ts b/packages/ocom/domain/src/domain/contexts/property/property/property.aggregate.ts
index 32af7421a..0c7ae9714 100644
--- a/packages/ocom/domain/src/domain/contexts/property/property/property.aggregate.ts
+++ b/packages/ocom/domain/src/domain/contexts/property/property/property.aggregate.ts
@@ -43,20 +43,35 @@ export interface PropertyEntityReference extends Readonly extends AggregateRoot implements PropertyEntityReference {
private isNew: boolean = false;
private visaCache: PropertyVisa | undefined;
+ /**
+ * Persistence adapters commonly write owner references as raw identifiers.
+ * Retain an explicitly assigned trusted owner for the lifetime of this
+ * aggregate so own-property authorization does not have to dereference an
+ * unpopulated persistence relation during creation or reassignment.
+ */
+ private assignedOwner: MemberEntityReference | null | undefined;
- public static getNewInstance(newProps: props, propertyName: string, community: CommunityEntityReference, passport: Passport): Property {
+ public static getNewInstance(newProps: props, propertyName: string, community: CommunityEntityReference, owner: MemberEntityReference | null, passport: Passport): Property {
const property = new Property(newProps, passport);
property.isNew = true;
- property.propertyName = propertyName;
- property.community = community;
- property.listedForSale = false;
- property.listedForRent = false;
- property.listedForLease = false;
- property.listedInDirectory = false;
- property.addIntegrationEvent(PropertyCreatedEvent, {
- id: property.props.id,
- });
- property.isNew = false;
+ try {
+ // The owner must be present before creation capability and defaults are
+ // evaluated: an own-property editor's visa derives ownership from this
+ // aggregate rather than from untrusted request input.
+ property.community = community;
+ property.owner = owner;
+ property.assertCanCreateProperty();
+ property.propertyName = propertyName;
+ property.listedForSale = false;
+ property.listedForRent = false;
+ property.listedForLease = false;
+ property.listedInDirectory = false;
+ property.addIntegrationEvent(PropertyCreatedEvent, {
+ id: property.props.id,
+ });
+ } finally {
+ property.isNew = false;
+ }
return property;
}
@@ -76,6 +91,22 @@ export class Property extends AggregateRoot extends AggregateRoot | null): void {
+ if (owner && owner.community.id !== this.props.community.id) {
+ throw new Error("Property owner must belong to the property's community");
+ }
+ }
+
private normalizeTags(tags: string[]): string[] {
const normalized = tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0);
if (normalized.length > 50) {
@@ -136,14 +173,16 @@ export class Property extends AggregateRoot extends AggregateRoot extends AggregateRoot extends AggregateRoot extends Repository> {
- getNewInstance(propertyName: string, community: CommunityEntityReference): Promise>;
+ getNewInstance(propertyName: string, community: CommunityEntityReference, owner: MemberEntityReference | null): Promise