From 34559ccbfaa01e4a269515c45aebf272f86bee93 Mon Sep 17 00:00:00 2001 From: Wildan Muhlis Date: Sat, 25 Apr 2026 00:50:03 +0700 Subject: [PATCH 1/3] Skip onboarding tasks in new workspace for already-onboarded users --- src/libs/actions/Policy/Policy.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index e3ae881fc9fb..ea63be8b3526 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -2885,12 +2885,7 @@ function buildPolicyData(options: BuildPolicyDataOptions): OnyxData Date: Tue, 28 Apr 2026 05:16:47 +0700 Subject: [PATCH 2/3] Add unit tests for buildPolicyData onboarding-tasks guard --- tests/actions/PolicyTest.ts | 136 +++++++++++++++++++++++++++++++++--- 1 file changed, 127 insertions(+), 9 deletions(-) diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index c9c70984b8ba..c81803735e5e 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -185,9 +185,11 @@ describe('actions/Policy', () => { // We do not pass tasks to `#admins` channel in favour of backed generated followup-list const expectedManageTeamDefaultTasksCount = 0; - // After filtering, two actions are added to the list =- signoff message (+1) and default create action (+1) + // The user has already completed onboarding (introSelected.choice is set), so the onboarding-tasks block + // in buildPolicyData is skipped — no signoff message is attached to the new workspace's #admins room. + // Only the default CREATED action remains. const expectedReportActionsOfTypeCreatedCount = 1; - const expectedSignOffMessagesCount = 1; + const expectedSignOffMessagesCount = 0; expect(adminReportActions.length).toBe(expectedManageTeamDefaultTasksCount + expectedReportActionsOfTypeCreatedCount + expectedSignOffMessagesCount); let reportActionsOfTypeCreatedCount = 0; @@ -1219,16 +1221,16 @@ describe('actions/Policy', () => { const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); const policyID = Policy.generatePolicyID(); - // When creating a workspace with isSelfTourViewed set to true - // Use LOOKING_AROUND as introSelected.choice to ensure VIEW_TOUR task is included - // (VIEW_TOUR is filtered out when both introSelected.choice and engagementChoice are MANAGE_TEAM) + // When creating a workspace with isSelfTourViewed set to true. + // introSelected.choice is left undefined to simulate a user who hasn't completed onboarding yet — + // that's the only state in which the onboarding-tasks block runs (see buildPolicyData guard). Policy.createWorkspace({ policyOwnerEmail: ESH_EMAIL, makeMeAdmin: true, policyName: WORKSPACE_NAME, policyID, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, - introSelected: {choice: CONST.ONBOARDING_CHOICES.LOOKING_AROUND}, + introSelected: {}, currentUserAccountIDParam: ESH_ACCOUNT_ID, currentUserEmailParam: ESH_EMAIL, isSelfTourViewed: true, @@ -1262,15 +1264,16 @@ describe('actions/Policy', () => { const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); const policyID = Policy.generatePolicyID(); - // When creating a workspace with isSelfTourViewed set to false - // Use LOOKING_AROUND as introSelected.choice to ensure VIEW_TOUR task is included + // When creating a workspace with isSelfTourViewed set to false. + // introSelected.choice is left undefined to simulate a user who hasn't completed onboarding yet — + // that's the only state in which the onboarding-tasks block runs (see buildPolicyData guard). Policy.createWorkspace({ policyOwnerEmail: ESH_EMAIL, makeMeAdmin: true, policyName: WORKSPACE_NAME, policyID, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, - introSelected: {choice: CONST.ONBOARDING_CHOICES.LOOKING_AROUND}, + introSelected: {}, currentUserAccountIDParam: ESH_ACCOUNT_ID, currentUserEmailParam: ESH_EMAIL, isSelfTourViewed: false, @@ -1373,6 +1376,121 @@ describe('actions/Policy', () => { apiWriteSpy.mockRestore(); }); + // The guard in buildPolicyData decides whether to attach onboarding tasks (guidedSetupData) + // to the new workspace. Users who already finished a Concierge-based onboarding flow + // (TRACK_WORKSPACE / EMPLOYER / SUBMIT / CHAT_SPLIT / LOOKING_AROUND / PERSONAL_SPEND) + // never have introSelected.createWorkspace set, so the previous `!introSelected?.createWorkspace` + // check evaluated truthy and leaked the tasks into the new workspace's #admins room. + // The fix swaps that for `!introSelected?.choice`, which is populated by every completeOnboarding call. + const onboardedChoices = [ + CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE, + CONST.ONBOARDING_CHOICES.EMPLOYER, + CONST.ONBOARDING_CHOICES.SUBMIT, + CONST.ONBOARDING_CHOICES.CHAT_SPLIT, + CONST.ONBOARDING_CHOICES.LOOKING_AROUND, + CONST.ONBOARDING_CHOICES.PERSONAL_SPEND, + CONST.ONBOARDING_CHOICES.MANAGE_TEAM, + ] as const; + + it.each(onboardedChoices)('should not add onboarding tasks when user already completed onboarding (choice=%s)', async (choice) => { + await Onyx.set(ONYXKEYS.SESSION, {email: ESH_EMAIL, accountID: ESH_ACCOUNT_ID}); + await waitForBatchedUpdates(); + + const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + const policyID = Policy.generatePolicyID(); + + // When creating a workspace and the user has already completed a guided onboarding flow, + // introSelected.choice is populated but introSelected.createWorkspace is not (Concierge-based flows + // never set it; MANAGE_TEAM only sets it for the *first* workspace, not subsequent ones). + Policy.createWorkspace({ + policyOwnerEmail: ESH_EMAIL, + makeMeAdmin: true, + policyName: WORKSPACE_NAME, + policyID, + engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, + introSelected: {choice}, + currentUserAccountIDParam: ESH_ACCOUNT_ID, + currentUserEmailParam: ESH_EMAIL, + isSelfTourViewed: false, + betas: undefined, + hasActiveAdminPolicies: false, + }); + await waitForBatchedUpdates(); + + // Then guidedSetupData should NOT be sent, so the #admins room of the new workspace stays empty of onboarding tasks. + const apiCallArgs = apiWriteSpy.mock.calls.find((call) => call.at(0) === WRITE_COMMANDS.CREATE_WORKSPACE); + expect(apiCallArgs).toBeDefined(); + const params = apiCallArgs?.[1] as {guidedSetupData?: string}; + expect(params.guidedSetupData).toBeUndefined(); + + apiWriteSpy.mockRestore(); + }); + + it('should add onboarding tasks when the user has not yet selected an onboarding choice', async () => { + await Onyx.set(ONYXKEYS.SESSION, {email: ESH_EMAIL, accountID: ESH_ACCOUNT_ID}); + await waitForBatchedUpdates(); + + const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + const policyID = Policy.generatePolicyID(); + + // When creating a workspace before the user has gone through guided onboarding (introSelected.choice is undefined), + // the block should run so that onboarding tasks are attached to the new workspace. + Policy.createWorkspace({ + policyOwnerEmail: ESH_EMAIL, + makeMeAdmin: true, + policyName: WORKSPACE_NAME, + policyID, + engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, + introSelected: {}, + currentUserAccountIDParam: ESH_ACCOUNT_ID, + currentUserEmailParam: ESH_EMAIL, + isSelfTourViewed: false, + betas: undefined, + hasActiveAdminPolicies: false, + }); + await waitForBatchedUpdates(); + + // Then guidedSetupData should be sent. + const apiCallArgs = apiWriteSpy.mock.calls.find((call) => call.at(0) === WRITE_COMMANDS.CREATE_WORKSPACE); + expect(apiCallArgs).toBeDefined(); + const params = apiCallArgs?.[1] as {guidedSetupData?: string}; + expect(params.guidedSetupData).toBeDefined(); + + apiWriteSpy.mockRestore(); + }); + + it('should add onboarding tasks for TEST_DRIVE_RECEIVER even when choice is set', async () => { + await Onyx.set(ONYXKEYS.SESSION, {email: ESH_EMAIL, accountID: ESH_ACCOUNT_ID}); + await waitForBatchedUpdates(); + + const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + const policyID = Policy.generatePolicyID(); + + // Even when introSelected.choice is populated, TEST_DRIVE_RECEIVER must still enter the block via + // the first disjunct so that the downstream Concierge createWorkspace task gets completed. + Policy.createWorkspace({ + policyOwnerEmail: ESH_EMAIL, + makeMeAdmin: true, + policyName: WORKSPACE_NAME, + policyID, + engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, + introSelected: {choice: CONST.ONBOARDING_CHOICES.TEST_DRIVE_RECEIVER}, + currentUserAccountIDParam: ESH_ACCOUNT_ID, + currentUserEmailParam: ESH_EMAIL, + isSelfTourViewed: false, + betas: undefined, + hasActiveAdminPolicies: false, + }); + await waitForBatchedUpdates(); + + const apiCallArgs = apiWriteSpy.mock.calls.find((call) => call.at(0) === WRITE_COMMANDS.CREATE_WORKSPACE); + expect(apiCallArgs).toBeDefined(); + const params = apiCallArgs?.[1] as {guidedSetupData?: string}; + expect(params.guidedSetupData).toBeDefined(); + + apiWriteSpy.mockRestore(); + }); + it('should publish a workspace created event if this is their first policy', () => { Policy.createWorkspace({ policyOwnerEmail: ESH_EMAIL, From c2c3dbe7df24d0e80b8c2b3d9142902247c2514b Mon Sep 17 00:00:00 2001 From: Wildan Muhlis Date: Wed, 29 Apr 2026 06:07:53 +0700 Subject: [PATCH 3/3] Add coverage test for prepareOnboardingOnyxData early-return path --- tests/actions/PolicyTest.ts | 40 +++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index c81803735e5e..5650c4235548 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -1491,6 +1491,46 @@ describe('actions/Policy', () => { apiWriteSpy.mockRestore(); }); + it('should bail out of onboarding-tasks block if prepareOnboardingOnyxData returns undefined', async () => { + await Onyx.set(ONYXKEYS.SESSION, {email: ESH_EMAIL, accountID: ESH_ACCOUNT_ID}); + await waitForBatchedUpdates(); + + const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + // Force prepareOnboardingOnyxData to return undefined so the early-return path inside the guarded block runs. + // This mirrors the real-world case where the target chat (Concierge for non-MANAGE_TEAM flows) cannot be resolved. + const prepareSpy = jest.spyOn(ReportUtils, 'prepareOnboardingOnyxData').mockReturnValue(undefined); + const policyID = Policy.generatePolicyID(); + + // introSelected.choice is undefined so the block enters; engagementChoice is non-MANAGE_TEAM + // so prepareOnboardingOnyxData would normally route to Concierge — and since the mock returns undefined, + // buildPolicyData should return early without setting guidedSetupData. + Policy.createWorkspace({ + policyOwnerEmail: ESH_EMAIL, + makeMeAdmin: true, + policyName: WORKSPACE_NAME, + policyID, + engagementChoice: CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE, + introSelected: {}, + currentUserAccountIDParam: ESH_ACCOUNT_ID, + currentUserEmailParam: ESH_EMAIL, + isSelfTourViewed: false, + betas: undefined, + hasActiveAdminPolicies: false, + }); + await waitForBatchedUpdates(); + + expect(prepareSpy).toHaveBeenCalled(); + const apiCallArgs = apiWriteSpy.mock.calls.find((call) => call.at(0) === WRITE_COMMANDS.CREATE_WORKSPACE); + expect(apiCallArgs).toBeDefined(); + const params = apiCallArgs?.[1] as {guidedSetupData?: string; bespokeWelcomeMessage?: string}; + // Early-return path should not populate the onboarding fields. + expect(params.guidedSetupData).toBeUndefined(); + expect(params.bespokeWelcomeMessage).toBeUndefined(); + + prepareSpy.mockRestore(); + apiWriteSpy.mockRestore(); + }); + it('should publish a workspace created event if this is their first policy', () => { Policy.createWorkspace({ policyOwnerEmail: ESH_EMAIL,