diff --git a/clientlibs/java/src/main/java/org/upgradeplatform/client/ExperimentClient.java b/clientlibs/java/src/main/java/org/upgradeplatform/client/ExperimentClient.java index 6c14034b8c..a9baeaa3f2 100644 --- a/clientlibs/java/src/main/java/org/upgradeplatform/client/ExperimentClient.java +++ b/clientlibs/java/src/main/java/org/upgradeplatform/client/ExperimentClient.java @@ -14,6 +14,7 @@ import static org.upgradeplatform.utils.Utils.SET_WORKING_GROUP; import static org.upgradeplatform.utils.Utils.isStringNull; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -263,7 +264,7 @@ public void failed(Throwable throwable) { /** @param site This is matched case-insensitively */ public void getExperimentCondition(String site, final ResponseCallback callbacks) { - getExperimentCondition(site, null, callbacks); + getExperimentCondition(site, null, false, callbacks); } /** @@ -272,11 +273,19 @@ public void getExperimentCondition(String site, final ResponseCallback callbacks) { - getAllExperimentConditions(new ResponseCallback>() { - @Override - public void onSuccess(@NonNull List experiments) { + getExperimentCondition(site, target, false, callbacks); + } - ExperimentsResponse resultExperimentsResponse = findExperimentResponse(site, target, experiments); + /** + * @param site This is matched case-insensitively + * @param target This is matched case-insensitively + * @param ignoreCache If true, always fetch from API for this decision point + */ + public void getExperimentCondition(String site, String target, boolean ignoreCache, + final ResponseCallback callbacks) { + getAllExperimentConditions(site, target, ignoreCache, new ResponseCallback() { + @Override + public void onSuccess(@NonNull ExperimentsResponse resultExperimentsResponse) { Map assignedFactor = resultExperimentsResponse.getAssignedFactor() != null ? resultExperimentsResponse.getAssignedFactor()[0] : null; @@ -303,7 +312,16 @@ public void onError(@NonNull ErrorResponse error) { /** @param site This is matched case-insensitively */ public void getAllExperimentConditions(String site, final ResponseCallback callbacks) { - getAllExperimentConditions(site, null, callbacks); + getAllExperimentConditions(site, null, false, callbacks); + } + + /** + * @param site This is matched case-insensitively + * @param ignoreCache If true, always fetch from API for this decision point + */ + public void getAllExperimentConditions(String site, boolean ignoreCache, + final ResponseCallback callbacks) { + getAllExperimentConditions(site, null, ignoreCache, callbacks); } /** @@ -312,37 +330,124 @@ public void getAllExperimentConditions(String site, */ public void getAllExperimentConditions(String site, String target, final ResponseCallback callbacks) { - getAllExperimentConditions(new ResponseCallback>() { - @Override - public void onSuccess(@NonNull List experiments) { - - ExperimentsResponse resultCondition = findExperimentResponse(site, target, experiments); + getAllExperimentConditions(site, target, false, callbacks); + } + /** + * @param site This is matched case-insensitively + * @param target This is matched case-insensitively + * @param ignoreCache If true, always fetch from API for this decision point + */ + public void getAllExperimentConditions(String site, String target, boolean ignoreCache, + final ResponseCallback callbacks) { + if (!ignoreCache && this.allExperiments != null) { + Optional cachedCondition = findExperimentResponseOptional(site, target, + this.allExperiments); + if (cachedCondition.isPresent()) { if (callbacks != null) { - callbacks.onSuccess(resultCondition); + callbacks.onSuccess(cachedCondition.get()); } + return; } + } - @Override - public void onError(@NonNull ErrorResponse error) { - if (callbacks != null) - callbacks.onError(error); + String normalizedTarget = target == null ? "" : target; + ExperimentRequest experimentRequest = new ExperimentRequest(this.context, site, normalizedTarget); + AsyncInvoker invocation = this.apiService.prepareRequest(GET_ALL_EXPERIMENTS); + Entity requestContent = Entity.json(experimentRequest); - } - }); + invocation.post(requestContent, + new PublishingRetryCallback<>(invocation, requestContent, MAX_RETRIES, RequestType.POST, + new InvocationCallback() { + + @Override + public void completed(Response response) { + if (response.getStatus() == Response.Status.OK.getStatusCode()) { + response.bufferEntity(); + try { + List experiments = response + .readEntity(new GenericType>() { + }); + upsertCachedExperiments(experiments); + ExperimentsResponse resultCondition = findExperimentResponse(site, target, + experiments); + if (callbacks != null) { + callbacks.onSuccess(resultCondition); + } + } catch (ProcessingException pe) { + if (callbacks != null) { + callbacks.onError( + new ErrorResponse(pe.getMessage() + "; cause: " + pe.getCause() + + "; body: " + response.readEntity(String.class))); + } + } + } else { + String status = Response.Status.fromStatusCode(response.getStatus()).toString(); + ErrorResponse error = new ErrorResponse(response.getStatus(), + response.readEntity(String.class), status); + if (callbacks != null) + callbacks.onError(error); + } + } + + @Override + public void failed(Throwable throwable) { + callbacks.onError(new ErrorResponse(throwable.getMessage())); + } + })); } - private ExperimentsResponse findExperimentResponse(String site, String target, + private Optional findExperimentResponseOptional(String site, String target, List experiments) { return experiments.stream() .filter(t -> t.getSite().equalsIgnoreCase(site) && (isStringNull(target) ? isStringNull(t.getTarget().toString()) : t.getTarget().toString().equalsIgnoreCase(target))) .findFirst() - .map(ExperimentClient::copyExperimentResponse) + .map(ExperimentClient::copyExperimentResponse); + } + + private ExperimentsResponse findExperimentResponse(String site, String target, + List experiments) { + return findExperimentResponseOptional(site, target, experiments) .orElse(new ExperimentsResponse()); } + private void upsertCachedExperiments(List fetchedExperiments) { + List mergedCache = this.allExperiments == null + ? new ArrayList<>() + : new ArrayList<>(this.allExperiments); + + if (this.allExperiments == null) { + this.allExperiments = fetchedExperiments.stream() + .map(ExperimentClient::copyExperimentResponse) + .toList(); + return; + } + + for (ExperimentsResponse fetched : fetchedExperiments) { + String fetchedTarget = fetched.getTarget() == null ? null : fetched.getTarget().toString(); + boolean updated = false; + for (int i = 0; i < mergedCache.size(); i++) { + ExperimentsResponse existing = mergedCache.get(i); + String existingTarget = existing.getTarget() == null ? null : existing.getTarget().toString(); + if (existing.getSite().equalsIgnoreCase(fetched.getSite()) && + (isStringNull(existingTarget) ? isStringNull(fetchedTarget) + : existingTarget.equalsIgnoreCase(fetchedTarget))) { + mergedCache.set(i, copyExperimentResponse(fetched)); + updated = true; + break; + } + } + + if (!updated) { + mergedCache.add(copyExperimentResponse(fetched)); + } + } + + this.allExperiments = mergedCache; + } + private void rotateConditions(String experimentId) { if (this.allExperiments == null || isStringNull(experimentId)) { return; diff --git a/clientlibs/java/src/main/java/org/upgradeplatform/requestbeans/ExperimentRequest.java b/clientlibs/java/src/main/java/org/upgradeplatform/requestbeans/ExperimentRequest.java index cb0e356839..ca9697b21e 100644 --- a/clientlibs/java/src/main/java/org/upgradeplatform/requestbeans/ExperimentRequest.java +++ b/clientlibs/java/src/main/java/org/upgradeplatform/requestbeans/ExperimentRequest.java @@ -1,13 +1,50 @@ package org.upgradeplatform.requestbeans; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) public class ExperimentRequest { + public static class DecisionPoint { + private String site; + private String target; + + public DecisionPoint(String site, String target) { + this.site = site; + this.target = target; + } + + public String getSite() { + return site; + } + + public void setSite(String site) { + this.site = site; + } + + public String getTarget() { + return target; + } + + public void setTarget(String target) { + this.target = target; + } + } + private String context; - + private DecisionPoint decisionPoint; + public ExperimentRequest(String context) { super(); this.context = context; } + public ExperimentRequest(String context, String site, String target) { + super(); + this.context = context; + this.decisionPoint = new DecisionPoint(site, target); + } + public String getContext() { return context; } @@ -15,4 +52,12 @@ public String getContext() { public void setContext(String context) { this.context = context; } + + public DecisionPoint getDecisionPoint() { + return decisionPoint; + } + + public void setDecisionPoint(DecisionPoint decisionPoint) { + this.decisionPoint = decisionPoint; + } } diff --git a/clientlibs/js/src/ApiService/ApiService.spec.ts b/clientlibs/js/src/ApiService/ApiService.spec.ts index 656ee8315c..5aef50c81f 100644 --- a/clientlibs/js/src/ApiService/ApiService.spec.ts +++ b/clientlibs/js/src/ApiService/ApiService.spec.ts @@ -210,6 +210,17 @@ describe('ApiService', () => { expect(mockHttpClient.doPost).toHaveBeenCalledWith(expectedUrl, requestBody, expectedOptions); }); + + it('should call sendRequest with context, site and normalized target', async () => { + const requestBody: UpGradeClientRequests.IGetAllExperimentConditionsRequestBody = { + context: defaultConfig.context, + decisionPoint: { site: 'siteA', target: '' }, + }; + + await apiService.getAllExperimentConditions('siteA', undefined); + + expect(mockHttpClient.doPost).toHaveBeenCalledWith(expectedUrl, requestBody, expectedOptions); + }); }); describe('#log', () => { diff --git a/clientlibs/js/src/ApiService/ApiService.ts b/clientlibs/js/src/ApiService/ApiService.ts index aec63c460a..0b87ffca62 100644 --- a/clientlibs/js/src/ApiService/ApiService.ts +++ b/clientlibs/js/src/ApiService/ApiService.ts @@ -210,11 +210,15 @@ export default class ApiService { }); } - public getAllExperimentConditions(): Promise { + public getAllExperimentConditions(site?: string | null, target?: string | null): Promise { const requestBody: UpGradeClientRequests.IGetAllExperimentConditionsRequestBody = { context: this.context, }; + if (site != null) { + requestBody.decisionPoint = { site, target: target ?? '' }; + } + return this.sendRequest({ path: this.api.getAllExperimentConditions, method: UpGradeClientEnums.REQUEST_METHOD.POST, diff --git a/clientlibs/js/src/DataService/DataService.spec.ts b/clientlibs/js/src/DataService/DataService.spec.ts index ff0a1b2a71..ed5ac382d7 100644 --- a/clientlibs/js/src/DataService/DataService.spec.ts +++ b/clientlibs/js/src/DataService/DataService.spec.ts @@ -100,6 +100,71 @@ describe('DataService', () => { }); }); + describe('#upsertExperimentAssignmentData', () => { + it('should replace existing decision point assignment', () => { + const cachedAssignments: IExperimentAssignmentv5[] = [ + { + site: 'site', + target: 'target', + assignedCondition: [{ conditionCode: 'control', payload: null, id: '1', experimentId: 'exp1' }], + experimentType: EXPERIMENT_TYPE.SIMPLE, + }, + ]; + + const fetchedAssignments: IExperimentAssignmentv5[] = [ + { + site: 'site', + target: 'target', + assignedCondition: [{ conditionCode: 'variant', payload: null, id: '2', experimentId: 'exp1' }], + experimentType: EXPERIMENT_TYPE.SIMPLE, + }, + ]; + + dataService.setExperimentAssignmentData(cachedAssignments); + dataService.upsertExperimentAssignmentData(fetchedAssignments); + + expect(dataService.getExperimentAssignmentData()).toEqual(fetchedAssignments); + }); + + it('should preserve unrelated decision points while appending new one', () => { + const cachedAssignments: IExperimentAssignmentv5[] = [ + { + site: 'siteA', + target: 'targetA', + assignedCondition: [{ conditionCode: 'control', payload: null, id: '1', experimentId: 'expA' }], + experimentType: EXPERIMENT_TYPE.SIMPLE, + }, + ]; + + const fetchedAssignments: IExperimentAssignmentv5[] = [ + { + site: 'siteB', + target: 'targetB', + assignedCondition: [{ conditionCode: 'variant', payload: null, id: '2', experimentId: 'expB' }], + experimentType: EXPERIMENT_TYPE.SIMPLE, + }, + ]; + + dataService.setExperimentAssignmentData(cachedAssignments); + dataService.upsertExperimentAssignmentData(fetchedAssignments); + + expect(dataService.getExperimentAssignmentData()).toEqual([ + { + site: 'siteA', + target: 'targetA', + assignedCondition: [{ conditionCode: 'control', payload: null, id: '1', experimentId: 'expA' }], + experimentType: EXPERIMENT_TYPE.SIMPLE, + }, + { + site: 'siteB', + target: 'targetB', + assignedCondition: [{ conditionCode: 'variant', payload: null, id: '2', experimentId: 'expB' }], + experimentType: EXPERIMENT_TYPE.SIMPLE, + }, + ]); + }); + }); + describe('#getFeatureFlags', () => { it('should return the feature flags', () => { const featureFlagsKeys: string[] = ['testFlagKey']; diff --git a/clientlibs/js/src/DataService/DataService.ts b/clientlibs/js/src/DataService/DataService.ts index c1f4266a79..8d78444e83 100644 --- a/clientlibs/js/src/DataService/DataService.ts +++ b/clientlibs/js/src/DataService/DataService.ts @@ -34,6 +34,36 @@ export class DataService { this.experimentAssignmentData = experimentAssignmentData; } + upsertExperimentAssignmentData(experimentAssignmentData: IExperimentAssignmentv5[]) { + if (!Array.isArray(experimentAssignmentData)) { + return; + } + + if (!Array.isArray(this.experimentAssignmentData)) { + // Warm the cache even if this is an empty response. + this.experimentAssignmentData = [...experimentAssignmentData]; + return; + } + + if (experimentAssignmentData.length === 0) { + return; + } + + for (const incomingAssignment of experimentAssignmentData) { + const incomingTarget = incomingAssignment.target ?? ''; + const existingIndex = this.experimentAssignmentData.findIndex( + (existingAssignment) => + existingAssignment.site === incomingAssignment.site && (existingAssignment.target ?? '') === incomingTarget + ); + + if (existingIndex >= 0) { + this.experimentAssignmentData[existingIndex] = incomingAssignment; + } else { + this.experimentAssignmentData.push(incomingAssignment); + } + } + } + getFeatureFlags(): string[] { return this.featureFlags; } diff --git a/clientlibs/js/src/UpGradeClient/UpgradeClient.ts b/clientlibs/js/src/UpGradeClient/UpgradeClient.ts index 976984edc2..0be95a726a 100644 --- a/clientlibs/js/src/UpGradeClient/UpgradeClient.ts +++ b/clientlibs/js/src/UpGradeClient/UpgradeClient.ts @@ -292,34 +292,66 @@ export default class UpgradeClient { } /** - * This will return all the assignment for the given context. - * The return object contains site, target, experimentType, assignedCondition array and assignedFactor array(optional) - * Here assignedCondition and assignedFactors(For Factorial-experiment) are arrays - * They will return a stack of condition user will be assigned in that order - * For With-in subjects these stacks will be contain all conditions according to the chosen `Condition-Order` - * For Between subjects experiment both stack will return array containing single condition. - * @param options.ignoreCache If true, it will ignore the cached experiment assignments and fetch fresh data from the API. - * This is useful when you want to ensure you have the latest assignments. - * If false, it will return the cached assignments if available. + * Returns experiment assignments for the current context. + * + * When called without `site`, this fetches or returns cached assignments for the full context. + * When `site` is provided, this fetches or returns the cached assignment for that decision point. + * Decision-point fetches are merged into the in-memory cache and do not replace assignments for other decision points. + * + * The return value contains objects with `site`, `target`, `experimentType`, `assignedCondition`, + * and optionally `assignedFactor`. For factorial experiments, assigned conditions and factors are returned + * as ordered stacks. For between-subject experiments, the arrays typically contain a single assignment. + * + * @param options - Optional fetch settings. Use `ignoreCache` to force a fresh fetch. Use `site` and + * `target` to scope the request to a decision point. When `site` is provided and `target` is omitted, + * the request uses the empty target. * @example * ```typescript * const userId = "User1" * const context = "mathia" * * const getAllResponse: IExperimentAssignmentv5[] = await upgradeClient.getAllExperimentConditions(); + * const decisionPointResponse: IExperimentAssignmentv5[] = await upgradeClient.getAllExperimentConditions({ + * site: 'dashboard', + * target: '' + * }); * ``` */ - async getAllExperimentConditions(options = { ignoreCache: false }): Promise { - let response: IExperimentAssignmentv5[] = options.ignoreCache - ? null - : await this.dataService.getExperimentAssignmentData(); - if (response == null) { - response = await this.apiService.getAllExperimentConditions(); - if (Array.isArray(response)) { + async getAllExperimentConditions({ + ignoreCache = false, + site = null, + target = null, + }: { + ignoreCache?: boolean; + site?: string | null; + target?: string | null; + } = {}): Promise { + const hasDecisionPoint = site != null; + const cachedAssignments = this.dataService.getExperimentAssignmentData(); + + if (!ignoreCache && cachedAssignments != null) { + if (!hasDecisionPoint) { + return cachedAssignments; + } + + const cachedDecisionPointAssignment = this.dataService.findExperimentAssignmentBySiteAndTarget( + site, + target ?? '' + ); + if (cachedDecisionPointAssignment?.experimentType != null) { + return cachedAssignments; + } + } + + const response = await this.apiService.getAllExperimentConditions(site, target); + if (Array.isArray(response)) { + if (hasDecisionPoint) { + this.dataService.upsertExperimentAssignmentData(response); + } else { this.dataService.setExperimentAssignmentData(response); } } - return response; + return this.dataService.getExperimentAssignmentData(); } /** @@ -335,7 +367,10 @@ export default class UpgradeClient { */ async getDecisionPointAssignment(site: string, target = ''): Promise { - await this.getAllExperimentConditions(); + await this.getAllExperimentConditions({ + site, + target, + }); if (this.dataService.getExperimentAssignmentData()) { const experimentAssignment = this.dataService.findExperimentAssignmentBySiteAndTarget(site, target); diff --git a/clientlibs/js/src/types/requests.ts b/clientlibs/js/src/types/requests.ts index b085faca0c..31479dda8c 100644 --- a/clientlibs/js/src/types/requests.ts +++ b/clientlibs/js/src/types/requests.ts @@ -22,6 +22,10 @@ export namespace UpGradeClientRequests { export interface IGetAllExperimentConditionsRequestBody { context: string; + decisionPoint?: { + site: string; + target?: string; + }; } export type IGetAllFeatureFlagsRequestBody = diff --git a/clientlibs/python/src/upgrade_client_lib/api_service.py b/clientlibs/python/src/upgrade_client_lib/api_service.py index 8ad531e292..017aa7aa62 100644 --- a/clientlibs/python/src/upgrade_client_lib/api_service.py +++ b/clientlibs/python/src/upgrade_client_lib/api_service.py @@ -152,12 +152,22 @@ def set_working_group_sync(self, working_group: dict[str, str]) -> InitializeUse # get_all_experiment_conditions POST /api/v6/assign # ------------------------------------------------------------------ - async def get_all_experiment_conditions(self) -> list[ExperimentAssignment]: - data = await self._post_async("assign", {"context": self._context, "userId": self._user_id}) + async def get_all_experiment_conditions( + self, site: str | None = None, target: str | None = None + ) -> list[ExperimentAssignment]: + body: dict[str, Any] = {"context": self._context, "userId": self._user_id} + if site is not None: + body["decisionPoint"] = {"site": site, "target": target if target is not None else ""} + data = await self._post_async("assign", body) return [ExperimentAssignment.model_validate(item) for item in data] - def get_all_experiment_conditions_sync(self) -> list[ExperimentAssignment]: - data = self._post_sync("assign", {"context": self._context, "userId": self._user_id}) + def get_all_experiment_conditions_sync( + self, site: str | None = None, target: str | None = None + ) -> list[ExperimentAssignment]: + body: dict[str, Any] = {"context": self._context, "userId": self._user_id} + if site is not None: + body["decisionPoint"] = {"site": site, "target": target if target is not None else ""} + data = self._post_sync("assign", body) return [ExperimentAssignment.model_validate(item) for item in data] # ------------------------------------------------------------------ diff --git a/clientlibs/python/src/upgrade_client_lib/client.py b/clientlibs/python/src/upgrade_client_lib/client.py index b7598b418a..5ce6c85f2d 100644 --- a/clientlibs/python/src/upgrade_client_lib/client.py +++ b/clientlibs/python/src/upgrade_client_lib/client.py @@ -140,25 +140,50 @@ def set_working_group_sync( # ------------------------------------------------------------------ async def get_all_experiment_conditions( - self, ignore_cache: bool = False + self, + ignore_cache: bool = False, + site: str | None = None, + target: str | None = None, ) -> list[Assignment]: """Return all experiment assignments for this user and context. - Results are cached after the first fetch. Pass ``ignore_cache=True`` + Results are cached after the first fetch. Pass ``ignore_cache=True`` to force a fresh fetch and update the cache. + + When ``site`` is provided, the request is scoped to that decision + point. Decision-point fetches are merged into the in-memory cache and + do not replace assignments for other decision points. """ - cached = None if ignore_cache else self._data_service.get_all_assignments() - if cached is None: - fresh = await self._api_service.get_all_experiment_conditions() + normalized_target = "" if site is not None and target is None else target + cached = self._data_service.get_all_assignments() + + if not ignore_cache and cached is not None: + if site is None: + return [Assignment(a, self._api_service) for a in cached] + + cached_assignment = self._data_service.get_assignment(site, normalized_target or "") + if cached_assignment is not None: + return [Assignment(a, self._api_service) for a in cached] + + fresh = await self._api_service.get_all_experiment_conditions(site=site, target=normalized_target) + if site is None: self._data_service.set_assignments(fresh) - cached = fresh + else: + self._data_service.upsert_assignments(fresh) + + cached = self._data_service.get_all_assignments() or [] return [Assignment(a, self._api_service) for a in cached] def get_all_experiment_conditions_sync( - self, ignore_cache: bool = False + self, + ignore_cache: bool = False, + site: str | None = None, + target: str | None = None, ) -> list[Assignment]: """Synchronous variant of :meth:`get_all_experiment_conditions`.""" - return asyncio.run(self.get_all_experiment_conditions(ignore_cache=ignore_cache)) + return asyncio.run( + self.get_all_experiment_conditions(ignore_cache=ignore_cache, site=site, target=target) + ) # ------------------------------------------------------------------ # get_decision_point_assignment @@ -169,11 +194,10 @@ async def get_decision_point_assignment( ) -> Assignment | None: """Return the :class:`Assignment` for a specific decision point. - Fetches all experiment conditions first if the cache is cold. + Fetches the requested decision point if it is not already cached. Returns ``None`` when no experiment is running at this decision point. """ - if self._data_service.get_all_assignments() is None: - await self.get_all_experiment_conditions() + await self.get_all_experiment_conditions(site=site, target=target) raw = self._data_service.get_assignment(site, target) if raw is None: return None diff --git a/clientlibs/python/src/upgrade_client_lib/data_service.py b/clientlibs/python/src/upgrade_client_lib/data_service.py index 80a812dcb8..c88c418e71 100644 --- a/clientlibs/python/src/upgrade_client_lib/data_service.py +++ b/clientlibs/python/src/upgrade_client_lib/data_service.py @@ -41,9 +41,21 @@ def __init__(self) -> None: # Assignments # ------------------------------------------------------------------ + @staticmethod + def _assignment_key(site: str, target: str) -> str: + return f"{site}|{target}" + def set_assignments(self, assignments: list[ExperimentAssignment]) -> None: """Populate the assignment cache from a fresh API response.""" - self._assignments = {f"{a.site}|{a.target or ''}": a for a in assignments} + self._assignments = {self._assignment_key(a.site, a.target or ""): a for a in assignments} + + def upsert_assignments(self, assignments: list[ExperimentAssignment]) -> None: + """Merge fresh assignment rows into the cache without dropping other decision points.""" + if self._assignments is None: + self._assignments = {} + for assignment in assignments: + self._assignments[self._assignment_key(assignment.site, assignment.target or "")] = assignment + def get_all_assignments(self) -> list[ExperimentAssignment] | None: """Return all cached assignments, or ``None`` if the cache is cold.""" if self._assignments is None: @@ -54,7 +66,7 @@ def get_assignment(self, site: str, target: str) -> ExperimentAssignment | None: """Return the cached assignment for ``site``/``target``, or ``None``.""" if self._assignments is None: return None - return self._assignments.get(f"{site}|{target}") + return self._assignments.get(self._assignment_key(site, target)) def rotate_assignment(self, assignment: ExperimentAssignment) -> ExperimentAssignment: """Round-robin advance the condition (and factor) lists. diff --git a/clientlibs/python/src/upgrade_client_lib/types/requests.py b/clientlibs/python/src/upgrade_client_lib/types/requests.py index e76dda61ae..76e0120832 100644 --- a/clientlibs/python/src/upgrade_client_lib/types/requests.py +++ b/clientlibs/python/src/upgrade_client_lib/types/requests.py @@ -25,9 +25,15 @@ class SetWorkingGroupRequest(BaseModel): workingGroup: dict[str, str] +class DecisionPointRef(BaseModel): + site: str + target: str | None = None + + class AssignRequest(BaseModel): userId: str context: str + decisionPoint: DecisionPointRef | None = None class MarkDecisionPointCondition(BaseModel): diff --git a/clientlibs/python/tests/test_api_service.py b/clientlibs/python/tests/test_api_service.py index 71add228ad..0c21899004 100644 --- a/clientlibs/python/tests/test_api_service.py +++ b/clientlibs/python/tests/test_api_service.py @@ -194,6 +194,17 @@ async def test_sends_context(self) -> None: body = json.loads(route.calls[0].request.content) assert body["context"] == CONTEXT assert body["userId"] == USER_ID + assert "decisionPoint" not in body + + @respx.mock + async def test_sends_site_and_default_target_for_decision_point_fetch(self) -> None: + route = respx.post(f"{BASE}/assign").mock(return_value=Response(200, json=[])) + await make_service().get_all_experiment_conditions(site="home") + import json + + body = json.loads(route.calls[0].request.content) + assert body["decisionPoint"]["site"] == "home" + assert body["decisionPoint"]["target"] == "" @respx.mock async def test_empty_response(self) -> None: diff --git a/clientlibs/python/tests/test_client.py b/clientlibs/python/tests/test_client.py index 4d878172ac..6aa588063f 100644 --- a/clientlibs/python/tests/test_client.py +++ b/clientlibs/python/tests/test_client.py @@ -219,6 +219,42 @@ async def test_ignore_cache_refetches(self) -> None: await client.get_all_experiment_conditions(ignore_cache=True) assert route.call_count == 2 + @respx.mock + async def test_site_scoped_fetch_sends_default_target(self) -> None: + route = respx.post(f"{BASE}/assign").mock(return_value=Response(200, json=[])) + await make_client().get_all_experiment_conditions(site="home") + body = json.loads(route.calls[0].request.content) + assert body["decisionPoint"]["site"] == "home" + assert body["decisionPoint"]["target"] == "" + + @respx.mock + async def test_site_scoped_fetch_merges_into_warm_cache(self) -> None: + route = respx.post(f"{BASE}/assign").mock( + side_effect=[ + Response(200, json=ASSIGNMENT_PAYLOAD), + Response(200, json=FACTORIAL_PAYLOAD), + ] + ) + client = make_client() + + await client.get_all_experiment_conditions() + results = await client.get_all_experiment_conditions(site="quiz", target="hint") + + assert route.call_count == 2 + assert len(results) == 2 + assert client._data_service.get_assignment("home", "banner") is not None + assert client._data_service.get_assignment("quiz", "hint") is not None + + @respx.mock + async def test_site_scoped_fetch_uses_cached_decision_point(self) -> None: + route = respx.post(f"{BASE}/assign").mock(return_value=Response(200, json=ASSIGNMENT_PAYLOAD)) + client = make_client() + + await client.get_all_experiment_conditions(site="home", target="banner") + await client.get_all_experiment_conditions(site="home", target="banner") + + assert route.call_count == 1 + @respx.mock async def test_returns_assignment_objects(self) -> None: respx.post(f"{BASE}/assign").mock(return_value=Response(200, json=ASSIGNMENT_PAYLOAD)) @@ -267,6 +303,26 @@ async def test_auto_fetches_when_cold(self) -> None: await client.get_decision_point_assignment("home", "banner") assert route.call_count == 1 + @respx.mock + async def test_fetches_only_requested_decision_point_when_warm_cache_misses(self) -> None: + route = respx.post(f"{BASE}/assign").mock( + side_effect=[ + Response(200, json=ASSIGNMENT_PAYLOAD), + Response(200, json=FACTORIAL_PAYLOAD), + ] + ) + client = make_client() + + await client.get_all_experiment_conditions() + result = await client.get_decision_point_assignment("quiz", "hint") + + second_body = json.loads(route.calls[1].request.content) + assert result is not None + assert route.call_count == 2 + assert second_body["decisionPoint"]["site"] == "quiz" + assert second_body["decisionPoint"]["target"] == "hint" + assert client._data_service.get_assignment("home", "banner") is not None + @respx.mock async def test_uses_cache_on_second_call(self) -> None: route = respx.post(f"{BASE}/assign").mock(return_value=Response(200, json=ASSIGNMENT_PAYLOAD)) diff --git a/clientlibs/python/tests/test_data_service.py b/clientlibs/python/tests/test_data_service.py index f55a93e628..1650a2b194 100644 --- a/clientlibs/python/tests/test_data_service.py +++ b/clientlibs/python/tests/test_data_service.py @@ -108,6 +108,35 @@ def test_overwrite_replaces_previous(self) -> None: assert result is not None assert result.assignedCondition[0].conditionCode == "treatment" + def test_upsert_replaces_matching_assignment_without_dropping_others(self) -> None: + ds = DataService() + ds.set_assignments([ + make_assignment("home", "banner"), + make_assignment("checkout", "cta"), + ]) + ds.upsert_assignments( + [ + make_assignment( + "home", + "banner", + conditions=[AssignedCondition(id="cond-new", conditionCode="treatment")], + ) + ] + ) + + home = ds.get_assignment("home", "banner") + checkout = ds.get_assignment("checkout", "cta") + assert home is not None + assert home.assignedCondition[0].conditionCode == "treatment" + assert checkout is not None + + def test_upsert_warms_cold_cache(self) -> None: + ds = DataService() + ds.upsert_assignments([make_assignment("quiz", "hint")]) + result = ds.get_assignment("quiz", "hint") + assert result is not None + assert result.site == "quiz" + # --------------------------------------------------------------------------- # Feature-flag cache — set / get / has diff --git a/clientlibs/python/tests/test_types.py b/clientlibs/python/tests/test_types.py index 4c225093cc..199d2ae17f 100644 --- a/clientlibs/python/tests/test_types.py +++ b/clientlibs/python/tests/test_types.py @@ -113,6 +113,11 @@ def test_valid(self) -> None: assert req.userId == "user-1" assert req.context == "my-app" + def test_valid_with_decision_point(self) -> None: + req = AssignRequest(userId="user-1", context="my-app", decisionPoint={"site": "home", "target": ""}) + assert req.decisionPoint.site == "home" + assert req.decisionPoint.target == "" + class TestMarkDecisionPointRequest: def test_valid_minimal(self) -> None: diff --git a/packages/backend/src/api/controllers/ExperimentClientController.v5.ts b/packages/backend/src/api/controllers/ExperimentClientController.v5.ts index c3f4fc34e3..b17423c102 100644 --- a/packages/backend/src/api/controllers/ExperimentClientController.v5.ts +++ b/packages/backend/src/api/controllers/ExperimentClientController.v5.ts @@ -578,6 +578,7 @@ export class ExperimentClientController { const assignedData = await this.experimentAssignmentService.getAllExperimentConditions( experimentUserDoc, experiment.context, + experiment.decisionPoint, request.logger ); diff --git a/packages/backend/src/api/controllers/ExperimentClientController.v6.ts b/packages/backend/src/api/controllers/ExperimentClientController.v6.ts index a7e47a989a..3012befa49 100644 --- a/packages/backend/src/api/controllers/ExperimentClientController.v6.ts +++ b/packages/backend/src/api/controllers/ExperimentClientController.v6.ts @@ -472,10 +472,24 @@ export class ExperimentClientController { * required: true * schema: * type: object + * required: + * - context * properties: * context: * type: string * example: add + * decisionPoint: + * type: object + * required: + * - site + * properties: + * site: + * type: string + * minLength: 1 + * example: homepage + * target: + * type: string + * example: button * description: User Document * tags: * - Client Side SDK @@ -545,6 +559,7 @@ export class ExperimentClientController { const assignedData = await this.experimentAssignmentService.getAllExperimentConditions( experimentUserDoc, experiment.context, + experiment.decisionPoint, request.logger ); diff --git a/packages/backend/src/api/controllers/validators/ExperimentAssignmentValidator.ts b/packages/backend/src/api/controllers/validators/ExperimentAssignmentValidator.ts index 4362263355..de32e194ec 100644 --- a/packages/backend/src/api/controllers/validators/ExperimentAssignmentValidator.ts +++ b/packages/backend/src/api/controllers/validators/ExperimentAssignmentValidator.ts @@ -1,9 +1,58 @@ -import { IsNotEmpty, IsString } from 'class-validator'; +import { + IsNotEmpty, + IsString, + ValidateIf, + ValidateNested, + registerDecorator, + ValidationArguments, + ValidationOptions, +} from 'class-validator'; +import { Transform, Type } from 'class-transformer'; + +function RequireSiteWhenTargetProvided(validationOptions?: ValidationOptions) { + return function (target: DecisionPointValidator, propertyName: string) { + registerDecorator({ + target: target.constructor, + propertyName, + options: validationOptions, + validator: { + validate(value: unknown, args: ValidationArguments) { + const request = args.object as DecisionPointValidator; + if (value === undefined) { + return true; + } + + return request.site !== undefined; + }, + defaultMessage() { + return 'site must be provided when target is supplied.'; + }, + }, + }); + }; +} + +export class DecisionPointValidator { + @IsNotEmpty() + @IsString() + public site: string; + + @RequireSiteWhenTargetProvided() + @Transform(({ value, obj }) => (obj?.site !== undefined ? value ?? '' : value)) + @ValidateIf((_, value) => value !== undefined) + @IsString() + public target?: string; +} export class ExperimentAssignmentValidatorv6 { @IsNotEmpty() @IsString() public context: string; + + @ValidateIf((_, value) => value !== undefined) + @ValidateNested() + @Type(() => DecisionPointValidator) + public decisionPoint?: DecisionPointValidator; } export class ExperimentAssignmentValidator extends ExperimentAssignmentValidatorv6 { diff --git a/packages/backend/src/api/services/ExperimentAssignmentService.ts b/packages/backend/src/api/services/ExperimentAssignmentService.ts index 4db8adb466..0bc47fe96b 100644 --- a/packages/backend/src/api/services/ExperimentAssignmentService.ts +++ b/packages/backend/src/api/services/ExperimentAssignmentService.ts @@ -331,12 +331,16 @@ export class ExperimentAssignmentService { public async getAllExperimentConditions( experimentUserDoc: RequestedExperimentUser, context: string, + decisionPoint: { site?: string; target?: string } | undefined, logger: UpgradeLogger ): Promise { logger.info({ message: `getAllExperimentConditions: User: ${experimentUserDoc.requestedUserId}` }); const userId = experimentUserDoc.id; const previewUser = await this.previewUserService.findOneFromCache(userId, logger); + const site = decisionPoint?.site; + const target = decisionPoint?.target ?? (site !== undefined ? '' : undefined); + /** Below are the detailed steps for the assignment process: * 1. Fetch experiments based on user type & moving conditionPayloads at the root level * 2. Check if user or group is globally excluded @@ -347,7 +351,7 @@ export class ExperimentAssignmentService { */ // 1. Fetch experiments based on user type & moving conditionPayloads at the root level - const experiments: Experiment[] = await this.getExperimentsForUser(previewUser, context); + const experiments: Experiment[] = await this.getExperimentsForUser(previewUser, context, site, target); // 2. Check if user or group is globally excluded const [isUserExcluded, isGroupExcluded] = await this.checkUserOrGroupIsGloballyExcluded(experimentUserDoc, context); @@ -691,10 +695,15 @@ export class ExperimentAssignmentService { } } - private async getExperimentsForUser(previewUser: PreviewUser, context: string): Promise { + private async getExperimentsForUser( + previewUser: PreviewUser, + context: string, + site: string, + target: string + ): Promise { const experiments = previewUser ? await this.experimentRepository.getValidExperimentsWithPreview(context) - : await this.experimentService.getCachedValidExperiments(context); + : await this.experimentService.getCachedValidExperiments(context, site, target); // adding conditionPayloads at the root level instead of inside conditions return experiments.map((exp) => this.experimentService.formattingConditionPayload(exp)); } diff --git a/packages/backend/src/api/services/ExperimentService.ts b/packages/backend/src/api/services/ExperimentService.ts index e7c7fa5639..584f577128 100644 --- a/packages/backend/src/api/services/ExperimentService.ts +++ b/packages/backend/src/api/services/ExperimentService.ts @@ -300,10 +300,21 @@ export class ExperimentService { }; } - public async getCachedValidExperiments(context: string): Promise { + public async getCachedValidExperiments(context: string, site: string, target: string): Promise { const cacheKey = CACHE_PREFIX.EXPERIMENT_KEY_PREFIX + context; + const fetchByDecisionPoint = site !== undefined && site !== null; return this.cacheService - .wrap(cacheKey, this.experimentRepository.getValidExperiments.bind(this.experimentRepository, context)) + .wrap( + cacheKey, + fetchByDecisionPoint + ? this.experimentRepository.getValidExperimentsForContextAndDecisionPoint.bind( + this.experimentRepository, + context, + site, + target + ) + : this.experimentRepository.getValidExperiments.bind(this.experimentRepository, context) + ) .then((validExperiment) => { return JSON.parse(JSON.stringify(validExperiment)); }); diff --git a/packages/backend/test/integration/UserNotDefined/index.ts b/packages/backend/test/integration/UserNotDefined/index.ts index 6eda67f2cc..76fb1b7942 100644 --- a/packages/backend/test/integration/UserNotDefined/index.ts +++ b/packages/backend/test/integration/UserNotDefined/index.ts @@ -13,6 +13,7 @@ export const UserNotDefined = async () => { experimentAssignmentService.getAllExperimentConditions( { ...experimentUserDoc, requestedUserId: experimentUsers[0].id }, null, + null, new UpgradeLogger() ) ).toEqual(Promise.resolve({})); diff --git a/packages/backend/test/integration/utils/index.ts b/packages/backend/test/integration/utils/index.ts index ef6abe894e..86b8aa5fee 100644 --- a/packages/backend/test/integration/utils/index.ts +++ b/packages/backend/test/integration/utils/index.ts @@ -94,6 +94,7 @@ export async function getAllExperimentCondition( return experimentAssignmentService.getAllExperimentConditions( { ...experimentUserDoc, requestedUserId: userId }, context, + null, logger ); } diff --git a/packages/backend/test/unit/controllers/ExperimentClientController.test.ts b/packages/backend/test/unit/controllers/ExperimentClientController.test.ts index e5838175ea..b380feaaf4 100644 --- a/packages/backend/test/unit/controllers/ExperimentClientController.test.ts +++ b/packages/backend/test/unit/controllers/ExperimentClientController.test.ts @@ -9,6 +9,13 @@ import { FeatureFlagService } from '../../../src/api/services/FeatureFlagService import { MetricService } from '../../../src/api/services/MetricService'; import { ClientLibMiddleware } from '../../../src/api/middlewares/ClientLibMiddleware'; import { UserCheckMiddleware } from '../../../src/api/middlewares/UserCheckMiddleware'; +import { useContainer as classValidatorUseContainer } from 'class-validator'; +import { validate } from 'class-validator'; +import { ExperimentClientController } from '../../../src/api/controllers/ExperimentClientController.v6'; +import { + ExperimentAssignmentValidatorv6, + DecisionPointValidator, +} from '../../../src/api/controllers/validators/ExperimentAssignmentValidator'; import ExperimentServiceMock from './mocks/ExperimentServiceMock'; import ExperimentAssignmentServiceMock from './mocks/ExperimentAssignmentServiceMock'; import ExperimentUserServiceMock from './mocks/ExperimentUserServiceMock'; @@ -17,20 +24,42 @@ import MetricServiceMock from './mocks/MetricServiceMock'; import ClientLibMiddlewareMock from './mocks/ClientLibMiddlewareMock'; import MockuserCheckMiddleware from './mocks/UserCheckMiddlewareMock'; -import { useContainer as classValidatorUseContainer } from 'class-validator'; - describe('Experiment Client Controller Testing', () => { + const experimentServiceMock = new ExperimentServiceMock(); + const experimentAssignmentServiceMock = new ExperimentAssignmentServiceMock(); + const experimentUserServiceMock = new ExperimentUserServiceMock(); + const featureFlagServiceMock = new FeatureFlagServiceMock(); + const metricServiceMock = new MetricServiceMock(); + const clientLibMiddlewareMock = new ClientLibMiddlewareMock(); + const userCheckMiddlewareMock = new MockuserCheckMiddleware(); + const controller = new ExperimentClientController( + experimentServiceMock as any, + experimentAssignmentServiceMock as any, + experimentUserServiceMock as any, + featureFlagServiceMock as any, + metricServiceMock as any, + {} as any + ); + const mockRequest = { + logger: { + info: jest.fn(), + }, + userDoc: { + id: 'u21', + }, + } as any; + beforeAll(() => { routingUseContainer(Container); classValidatorUseContainer(Container); - Container.set(ExperimentService, new ExperimentServiceMock()); - Container.set(ExperimentAssignmentService, new ExperimentAssignmentServiceMock()); - Container.set(ExperimentUserService, new ExperimentUserServiceMock()); - Container.set(FeatureFlagService, new FeatureFlagServiceMock()); - Container.set(MetricService, new MetricServiceMock()); - Container.set(ClientLibMiddleware, new ClientLibMiddlewareMock()); - Container.set(UserCheckMiddleware, new MockuserCheckMiddleware()); + Container.set(ExperimentService, experimentServiceMock); + Container.set(ExperimentAssignmentService, experimentAssignmentServiceMock); + Container.set(ExperimentUserService, experimentUserServiceMock); + Container.set(FeatureFlagService, featureFlagServiceMock); + Container.set(MetricService, metricServiceMock); + Container.set(ClientLibMiddleware, clientLibMiddlewareMock); + Container.set(UserCheckMiddleware, userCheckMiddlewareMock); }); afterAll(() => { @@ -58,8 +87,8 @@ describe('Experiment Client Controller Testing', () => { ], }; - test('Post request for /api/v5/init', () => { - return request(app) + test('Post request for /api/v5/init', async () => { + const response = await request(app) .post('/api/v5/init') .send({ id: '123', @@ -67,10 +96,12 @@ describe('Experiment Client Controller Testing', () => { .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200); + + expect(response.status).toBe(200); }); - test('Post request for /api/v5/groupmembership', () => { - return request(app) + test('Post request for /api/v5/groupmembership', async () => { + const response = await request(app) .patch('/api/v5/groupmembership') .send({ id: 'u21', @@ -81,10 +112,12 @@ describe('Experiment Client Controller Testing', () => { .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200); + + expect(response.status).toBe(200); }); - test('Post request for /api/v5/workinggroup', () => { - return request(app) + test('Post request for /api/v5/workinggroup', async () => { + const response = await request(app) .patch('/api/v5/workinggroup') .send({ id: 'u21', @@ -97,10 +130,12 @@ describe('Experiment Client Controller Testing', () => { .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200); + + expect(response.status).toBe(200); }); - test('Post request for /api/v5/mark', () => { - return request(app) + test('Post request for /api/v5/mark', async () => { + const response = await request(app) .post('/api/v5/mark') .send({ userId: 'u21', @@ -116,10 +151,12 @@ describe('Experiment Client Controller Testing', () => { .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200); + + expect(response.status).toBe(200); }); - test('Post request for /api/v5/mark with null target', () => { - return request(app) + test('Post request for /api/v5/mark with null target', async () => { + const response = await request(app) .post('/api/v5/mark') .send({ userId: 'u21', @@ -135,10 +172,12 @@ describe('Experiment Client Controller Testing', () => { .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200); + + expect(response.status).toBe(200); }); - test('Post request for /api/v5/mark with missing target', () => { - return request(app) + test('Post request for /api/v5/mark with missing target', async () => { + const response = await request(app) .post('/api/v5/mark') .send({ userId: 'u21', @@ -153,10 +192,12 @@ describe('Experiment Client Controller Testing', () => { .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200); + + expect(response.status).toBe(200); }); - test('Post request for /api/v5/assign', () => { - return request(app) + test('Post request for /api/v5/assign', async () => { + const response = await request(app) .post('/api/v5/assign') .send({ userId: 'u21', @@ -165,19 +206,23 @@ describe('Experiment Client Controller Testing', () => { .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200); + + expect(response.status).toBe(200); }); - test('Post request for /api/v5/log', () => { - return request(app) + test('Post request for /api/v5/log', async () => { + const response = await request(app) .post('/api/v5/log') .send(logData) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200); + + expect(response.status).toBe(200); }); - test('Post request for /api/v5/useraliases', () => { - return request(app) + test('Post request for /api/v5/useraliases', async () => { + const response = await request(app) .patch('/api/v5/useraliases') .send({ userId: 'u21', @@ -186,5 +231,68 @@ describe('Experiment Client Controller Testing', () => { .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200); + + expect(response.status).toBe(200); + }); + + describe('Post request for /api/v6/assign', () => { + test('rejects target without site', async () => { + const decisionPoint = Object.assign(new DecisionPointValidator(), { + target: 'W1', + }); + + const validator = Object.assign(new ExperimentAssignmentValidatorv6(), { + context: 'abc', + decisionPoint, + }); + + const errors = await validate(validator); + + expect(errors.some((error) => error.property === 'decisionPoint')).toBe(true); + }); + + test('rejects null target without site', async () => { + const decisionPoint = Object.assign(new DecisionPointValidator(), { + target: null, + }); + + const validator = Object.assign(new ExperimentAssignmentValidatorv6(), { + context: 'abc', + decisionPoint, + }); + + const errors = await validate(validator); + + expect(errors.some((error) => error.property === 'decisionPoint')).toBe(true); + }); + + test('normalizes missing target to an empty string when site is provided', async () => { + const getAllExperimentConditionsSpy = jest + .spyOn(experimentAssignmentServiceMock as any, 'getAllExperimentConditions') + .mockResolvedValue([]); + + const decisionPoint = Object.assign(new DecisionPointValidator(), { + site: 'CurriculumSequence', + }); + + const response = await controller.getAllExperimentConditions( + mockRequest, + Object.assign(new ExperimentAssignmentValidatorv6(), { + context: 'abc', + decisionPoint, + }) + ); + + expect(response).toEqual([]); + expect(getAllExperimentConditionsSpy).toHaveBeenCalledTimes(1); + expect(getAllExperimentConditionsSpy).toHaveBeenCalledWith( + mockRequest.userDoc, + 'abc', + decisionPoint, + mockRequest.logger + ); + + getAllExperimentConditionsSpy.mockRestore(); + }); }); }); diff --git a/packages/backend/test/unit/services/ExperimentAssignmentService.test.ts b/packages/backend/test/unit/services/ExperimentAssignmentService.test.ts index a0d5d7d789..3b32a4d14e 100644 --- a/packages/backend/test/unit/services/ExperimentAssignmentService.test.ts +++ b/packages/backend/test/unit/services/ExperimentAssignmentService.test.ts @@ -202,7 +202,7 @@ describe('Experiment Assignment Service Test', () => { { id: '77777777-7777-7777-7777-777777777777', subSegments: [], individualForSegment: [], groupForSegment: [] }, ]); - const result = await testedModule.getAllExperimentConditions(userDoc, context, loggerMock); + const result = await testedModule.getAllExperimentConditions(userDoc, context, undefined, loggerMock); expect(result).toEqual([]); sinon.assert.calledWith(loggerMock.info, { message: `getAllExperimentConditions: User: ${userDoc.requestedUserId}`, @@ -223,7 +223,7 @@ describe('Experiment Assignment Service Test', () => { testedModule.experimentService.getCachedValidExperiments = sandbox.stub().resolves([exp]); testedModule.experimentUserService = experimentUserServiceMock; - const result = await testedModule.getAllExperimentConditions(userDoc, context, loggerMock); + const result = await testedModule.getAllExperimentConditions(userDoc, context, undefined, loggerMock); const cond = { ...exp.conditions[0], experimentId: exp.id, payload: undefined }; expect(result.length).toEqual(1); expect(result[0].site).toEqual(exp.partitions[0].site); @@ -256,7 +256,7 @@ describe('Experiment Assignment Service Test', () => { testedModule.experimentService.getCachedValidExperiments = sandbox.stub().resolves([exp1, exp2]); testedModule.experimentUserService = experimentUserServiceMock; - const result = await testedModule.getAllExperimentConditions(userDoc, context, loggerMock); + const result = await testedModule.getAllExperimentConditions(userDoc, context, undefined, loggerMock); // Both experiments should independently assign — Exp1's pending DP must not create a pool // conflict that causes Exp2 to be excluded. Each experiment should produce its own assignment. @@ -284,7 +284,7 @@ describe('Experiment Assignment Service Test', () => { testedModule.experimentService.getCachedValidExperiments = sandbox.stub().resolves([exp]); testedModule.experimentUserService = experimentUserServiceMock; - const result = await testedModule.getAllExperimentConditions(userDoc, context, loggerMock); + const result = await testedModule.getAllExperimentConditions(userDoc, context, undefined, loggerMock); // Only the non-pending partition should produce an assignment expect(result.length).toEqual(1); @@ -306,7 +306,7 @@ describe('Experiment Assignment Service Test', () => { testedModule.experimentService.getCachedValidExperiments = sandbox.stub().resolves([exp]); testedModule.experimentUserService = experimentUserServiceMock; - const result = await testedModule.getAllExperimentConditions(userDoc, context, loggerMock); + const result = await testedModule.getAllExperimentConditions(userDoc, context, undefined, loggerMock); expect(result.length).toEqual(1); expect(result[0].site).toEqual(exp.partitions[0].site); @@ -329,7 +329,7 @@ describe('Experiment Assignment Service Test', () => { testedModule.experimentUserService = experimentUserServiceMock; testedModule.previewUserService.findOneFromCache = sandbox.stub().resolves({ id: userDoc.id, assignments: [] }); - const result = await testedModule.getAllExperimentConditions(userDoc, context, loggerMock); + const result = await testedModule.getAllExperimentConditions(userDoc, context, undefined, loggerMock); // Both DPs should be returned — pendingActivation must not filter them out for preview experiments expect(result.length).toEqual(2); @@ -347,7 +347,7 @@ describe('Experiment Assignment Service Test', () => { testedModule.experimentService.getCachedValidExperiments = sandbox.stub().resolves([exp]); testedModule.experimentUserService = experimentUserServiceMock; - const result = await testedModule.getAllExperimentConditions(userDoc, context, loggerMock); + const result = await testedModule.getAllExperimentConditions(userDoc, context, undefined, loggerMock); const factor = { Color: { @@ -387,7 +387,7 @@ describe('Experiment Assignment Service Test', () => { testedModule.experimentUserService = experimentUserServiceMock; testedModule.repeatedEnrollmentRepositoryMock = repeatedEnrollmentRepositoryMock; - const result = await testedModule.getAllExperimentConditions(userDoc, context, loggerMock); + const result = await testedModule.getAllExperimentConditions(userDoc, context, undefined, loggerMock); const cond = [ { conditionCode: exp.conditions[0].conditionCode, @@ -435,7 +435,7 @@ describe('Experiment Assignment Service Test', () => { testedModule.experimentService.getCachedValidExperiments = sandbox.stub().resolves([exp]); testedModule.experimentUserService = { getOriginalUserDoc: sandbox.stub().resolves(userDoc) }; - const result = await testedModule.getAllExperimentConditions(userDoc, context, loggerMock); + const result = await testedModule.getAllExperimentConditions(userDoc, context, undefined, loggerMock); // Both decision points should be returned expect(result.length).toEqual(2); @@ -484,7 +484,7 @@ describe('Experiment Assignment Service Test', () => { .resolves([{ payloadFound: conditionPayloadRepositoryMock, factorialObject: factorRepositoryMock }]); testedModule.experimentUserService = experimentUserServiceMock; - const result = await testedModule.getAllExperimentConditions(userDoc, context, loggerMock); + const result = await testedModule.getAllExperimentConditions(userDoc, context, undefined, loggerMock); const cond = { ...exp.conditions[0], experimentId: exp.id, payload: undefined }; expect(result.length).toEqual(1); @@ -546,7 +546,7 @@ describe('Experiment Assignment Service Test', () => { .resolves([{ payloadFound: conditionPayloadRepositoryMock, factorialObject: factorRepositoryMock }]); testedModule.experimentUserService = experimentUserServiceMock; - const result = await testedModule.getAllExperimentConditions(userDoc, context, loggerMock); + const result = await testedModule.getAllExperimentConditions(userDoc, context, undefined, loggerMock); expect(result.length).toEqual(1); expect(result[0].site).toEqual(exp.partitions[0].site); expect(result[0].target).toEqual(exp.partitions[0].target);