diff --git a/apps/web/src/app/billing/individual/upgrade/services/upgrade-flow.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/services/upgrade-flow.service.spec.ts new file mode 100644 index 00000000000..521dc58bf98 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/services/upgrade-flow.service.spec.ts @@ -0,0 +1,348 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DialogRef, DialogService } from "@bitwarden/components"; + +import { BitwardenSubscriber } from "../../../types"; +import { PersonalSubscriptionPricingTierIds } from "../../../types/subscription-pricing-tier"; +import { + UpgradeAccountDialogComponent, + UpgradeAccountDialogResult, + UpgradeAccountDialogStatus, +} from "../upgrade-account-dialog/upgrade-account-dialog.component"; +import { + UpgradePaymentDialogComponent, + UpgradePaymentDialogResult, +} from "../upgrade-payment-dialog/upgrade-payment-dialog.component"; + +import { UpgradeFlowResult, UpgradeFlowService } from "./upgrade-flow.service"; + +/** + * Creates a mock DialogRef that implements the required properties for testing + * @param result The result that will be emitted by the closed observable + * @returns A mock DialogRef object + */ +function createMockDialogRef(result: T): DialogRef { + // Create a mock that implements the DialogRef interface + return { + // The closed property is readonly in the actual DialogRef + closed: of(result), + } as DialogRef; +} + +// Mock the open method of a dialog component to return the provided DialogRefs +// Supports multiple calls by returning different refs in sequence +function mockDialogOpenMethod(component: any, ...refs: DialogRef[]) { + const spy = jest.spyOn(component, "open"); + refs.forEach((ref) => spy.mockReturnValueOnce(ref)); + return spy; +} + +describe("UpgradeFlowService", () => { + let sut: UpgradeFlowService; + let dialogService: MockProxy; + let accountService: MockProxy; + + // Mock account + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }; + + // Mock subscriber + const mockSubscriber: BitwardenSubscriber = { + type: "account", + data: mockAccount, + }; + + beforeEach(() => { + dialogService = mock(); + accountService = mock(); + + // Setup account service to return mock account + accountService.activeAccount$ = of(mockAccount); + + TestBed.configureTestingModule({ + providers: [ + UpgradeFlowService, + { provide: DialogService, useValue: dialogService }, + { provide: AccountService, useValue: accountService }, + ], + }); + + sut = TestBed.inject(UpgradeFlowService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("startUpgradeFlow", () => { + it("should return cancelled when upgrade account dialog is closed", async () => { + // Setup mock dialog references + const upgradeAccountDialogRef = createMockDialogRef({ + status: UpgradeAccountDialogStatus.Closed, + plan: null, + }); + + // Added to verify no payment dialog is opened + jest.spyOn(UpgradePaymentDialogComponent, "open"); + mockDialogOpenMethod(UpgradeAccountDialogComponent, upgradeAccountDialogRef); + + // Act + const result = await sut.startUpgradeFlow(); + + // Assert + expect(result).toBe(UpgradeFlowResult.Cancelled); + expect(UpgradeAccountDialogComponent.open).toHaveBeenCalledWith(dialogService); + expect(UpgradePaymentDialogComponent.open).not.toHaveBeenCalled(); + }); + + it("should return upgraded result when premium payment is successful", async () => { + // Arrange - Setup mock dialog references + const mockUpgradeDialogRef = createMockDialogRef({ + status: UpgradeAccountDialogStatus.ProceededToPayment, + plan: PersonalSubscriptionPricingTierIds.Premium, + }); + + const mockPaymentDialogRef = createMockDialogRef( + UpgradePaymentDialogResult.UpgradedToPremium, + ); + + mockDialogOpenMethod(UpgradeAccountDialogComponent, mockUpgradeDialogRef); + mockDialogOpenMethod(UpgradePaymentDialogComponent, mockPaymentDialogRef); + + // Act + const result = await sut.startUpgradeFlow(); + + // Assert + expect(result).toBe(UpgradeFlowResult.Upgraded); + expect(UpgradeAccountDialogComponent.open).toHaveBeenCalledWith(dialogService); + expect(UpgradePaymentDialogComponent.open).toHaveBeenCalledWith( + dialogService, + expect.objectContaining({ + data: expect.objectContaining({ + plan: PersonalSubscriptionPricingTierIds.Premium, + subscriber: mockSubscriber, + }), + }), + ); + }); + + it("should return upgraded result when families payment is successful", async () => { + // Arrange - Setup mock dialog references + const mockUpgradeDialogRef = createMockDialogRef({ + status: UpgradeAccountDialogStatus.ProceededToPayment, + plan: PersonalSubscriptionPricingTierIds.Families, + }); + + const mockPaymentDialogRef = createMockDialogRef( + UpgradePaymentDialogResult.UpgradedToFamilies, + ); + + mockDialogOpenMethod(UpgradeAccountDialogComponent, mockUpgradeDialogRef); + mockDialogOpenMethod(UpgradePaymentDialogComponent, mockPaymentDialogRef); + + // Act + const result = await sut.startUpgradeFlow(); + + // Assert + expect(result).toBe(UpgradeFlowResult.Upgraded); + expect(UpgradeAccountDialogComponent.open).toHaveBeenCalledWith(dialogService); + expect(UpgradePaymentDialogComponent.open).toHaveBeenCalledWith( + dialogService, + expect.objectContaining({ + data: expect.objectContaining({ + plan: PersonalSubscriptionPricingTierIds.Families, + subscriber: mockSubscriber, + }), + }), + ); + }); + + it("should return to upgrade dialog when user clicks back in payment dialog", async () => { + // Arrange - Setup mock dialog references for first cycle + const mockUpgradeDialogRef = createMockDialogRef({ + status: UpgradeAccountDialogStatus.ProceededToPayment, + plan: PersonalSubscriptionPricingTierIds.Premium, + }); + + const mockPaymentDialogRef = createMockDialogRef( + UpgradePaymentDialogResult.Back, + ); + + // Setup mock dialog for second cycle (when user cancels) + const mockSecondUpgradeDialogRef = createMockDialogRef({ + status: UpgradeAccountDialogStatus.Closed, + plan: null, + }); + + mockDialogOpenMethod( + UpgradeAccountDialogComponent, + mockUpgradeDialogRef, + mockSecondUpgradeDialogRef, + ); + + mockDialogOpenMethod(UpgradePaymentDialogComponent, mockPaymentDialogRef); + + // Act + const result = await sut.startUpgradeFlow(); + + // Assert + expect(result).toBe(UpgradeFlowResult.Cancelled); + expect(UpgradeAccountDialogComponent.open).toHaveBeenCalledTimes(2); + expect(UpgradePaymentDialogComponent.open).toHaveBeenCalledTimes(1); + expect(UpgradeAccountDialogComponent.open).toHaveBeenNthCalledWith(1, dialogService); + expect(UpgradeAccountDialogComponent.open).toHaveBeenNthCalledWith(2, dialogService); + expect(UpgradePaymentDialogComponent.open).toHaveBeenNthCalledWith(1, dialogService, { + data: { + plan: PersonalSubscriptionPricingTierIds.Premium, + subscriber: mockSubscriber, + }, + }); + }); + + it("should handle a successful upgrade flow with going back and forth", async () => { + // Arrange - Setup mock dialog references for first cycle + const mockUpgradeDialogRef = createMockDialogRef({ + status: UpgradeAccountDialogStatus.ProceededToPayment, + plan: PersonalSubscriptionPricingTierIds.Premium, + }); + + const mockPaymentDialogRef = createMockDialogRef( + UpgradePaymentDialogResult.Back, + ); + + // Setup mock dialog for second cycle (when user selects families plan) + const mockSecondUpgradeDialogRef = createMockDialogRef({ + status: UpgradeAccountDialogStatus.ProceededToPayment, + plan: PersonalSubscriptionPricingTierIds.Families, + }); + + const mockSecondPaymentDialogRef = createMockDialogRef( + UpgradePaymentDialogResult.UpgradedToFamilies, + ); + + mockDialogOpenMethod( + UpgradeAccountDialogComponent, + mockUpgradeDialogRef, + mockSecondUpgradeDialogRef, + ); + + mockDialogOpenMethod( + UpgradePaymentDialogComponent, + mockPaymentDialogRef, + mockSecondPaymentDialogRef, + ); + + // Act + const result = await sut.startUpgradeFlow(); + + // Assert + expect(result).toBe(UpgradeFlowResult.Upgraded); + expect(UpgradeAccountDialogComponent.open).toHaveBeenCalledTimes(2); + expect(UpgradePaymentDialogComponent.open).toHaveBeenCalledTimes(2); + expect(UpgradeAccountDialogComponent.open).toHaveBeenNthCalledWith(1, dialogService); + expect(UpgradeAccountDialogComponent.open).toHaveBeenNthCalledWith(2, dialogService); + expect(UpgradePaymentDialogComponent.open).toHaveBeenNthCalledWith(1, dialogService, { + data: { + plan: PersonalSubscriptionPricingTierIds.Premium, + subscriber: mockSubscriber, + }, + }); + expect(UpgradePaymentDialogComponent.open).toHaveBeenNthCalledWith(2, dialogService, { + data: { + plan: PersonalSubscriptionPricingTierIds.Families, + subscriber: mockSubscriber, + }, + }); + }); + + it("should return cancelled result if payment dialog is closed without a successful payment", async () => { + // Setup mock dialog references + const mockUpgradeDialogRef = createMockDialogRef({ + status: UpgradeAccountDialogStatus.ProceededToPayment, + plan: PersonalSubscriptionPricingTierIds.Premium, + }); + + const mockPaymentDialogRef = createMockDialogRef( + "cancelled" as any, + ); + + mockDialogOpenMethod(UpgradeAccountDialogComponent, mockUpgradeDialogRef); + mockDialogOpenMethod(UpgradePaymentDialogComponent, mockPaymentDialogRef); + + // Act + const result = await sut.startUpgradeFlow(); + + // Assert + expect(result).toBe(UpgradeFlowResult.Cancelled); + }); + + it("should throw error for missing account information", async () => { + // Setup account service to return null + accountService.activeAccount$ = of(null as any); + + // Expect error + await expect(sut.startUpgradeFlow()).rejects.toThrow(); + }); + it("should return cancelled if upgrade dialog returns null result", async () => { + // Setup mock dialog references + const upgradeAccountDialogRef = createMockDialogRef(null); + + // Added to verify no payment dialog is opened + jest.spyOn(UpgradePaymentDialogComponent, "open"); + mockDialogOpenMethod(UpgradeAccountDialogComponent, upgradeAccountDialogRef); + + // Act + const result = await sut.startUpgradeFlow(); + + // Assert + expect(result).toBe(UpgradeFlowResult.Cancelled); + expect(UpgradeAccountDialogComponent.open).toHaveBeenCalledWith(dialogService); + expect(UpgradePaymentDialogComponent.open).not.toHaveBeenCalled(); + }); + + it("should return cancelled if payment dialog returns null result", async () => { + // Setup mock dialog references + const mockUpgradeDialogRef = createMockDialogRef({ + status: UpgradeAccountDialogStatus.ProceededToPayment, + plan: PersonalSubscriptionPricingTierIds.Premium, + }); + + const mockPaymentDialogRef = createMockDialogRef(null); + + mockDialogOpenMethod(UpgradeAccountDialogComponent, mockUpgradeDialogRef); + mockDialogOpenMethod(UpgradePaymentDialogComponent, mockPaymentDialogRef); + + // Act + const result = await sut.startUpgradeFlow(); + + // Assert + expect(result).toBe(UpgradeFlowResult.Cancelled); + expect(UpgradeAccountDialogComponent.open).toHaveBeenCalledWith(dialogService); + expect(UpgradePaymentDialogComponent.open).toHaveBeenCalledWith( + dialogService, + expect.objectContaining({ + data: expect.objectContaining({ + plan: PersonalSubscriptionPricingTierIds.Premium, + subscriber: mockSubscriber, + }), + }), + ); + }); + + it("should throw error for missing account information", async () => { + // Setup account service to return null + accountService.activeAccount$ = of(null as any); + + // Expect error + await expect(sut.startUpgradeFlow()).rejects.toThrow(); + }); + }); +}); diff --git a/apps/web/src/app/billing/individual/upgrade/services/upgrade-flow.service.ts b/apps/web/src/app/billing/individual/upgrade/services/upgrade-flow.service.ts new file mode 100644 index 00000000000..3ee4666ccde --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/services/upgrade-flow.service.ts @@ -0,0 +1,110 @@ +import { Injectable } from "@angular/core"; +import { lastValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { DialogRef, DialogService } from "@bitwarden/components"; + +import { BitwardenSubscriber, mapAccountToSubscriber } from "../../../types"; +import { + UpgradeAccountDialogComponent, + UpgradeAccountDialogResult, + UpgradeAccountDialogStatus, +} from "../upgrade-account-dialog/upgrade-account-dialog.component"; +import { + UpgradePaymentDialogComponent, + UpgradePaymentDialogParams, + UpgradePaymentDialogResult, +} from "../upgrade-payment-dialog/upgrade-payment-dialog.component"; + +export const UpgradeFlowResult = { + Upgraded: "upgraded", + Cancelled: "cancelled", +} as const; + +export type UpgradeFlowResult = UnionOfValues; + +/** + * Service to manage the account upgrade flow through multiple dialogs + */ +@Injectable({ providedIn: "root" }) +export class UpgradeFlowService { + // References to open dialogs + private upgradeToPremiumDialogRef?: DialogRef; + private upgradePaymentDialogRef?: DialogRef; + private subscriber: BitwardenSubscriber | null = null; + + constructor( + private dialogService: DialogService, + private accountService: AccountService, + ) { + this.accountService.activeAccount$.pipe(mapAccountToSubscriber).subscribe((subscriber) => { + this.subscriber = subscriber; + }); + } + + /** + * Start the account upgrade flow + * + * This method will open the upgrade account dialog and handle the flow + * between it and the payment dialog if needed. + * + * @returns A promise resolving to the upgrade flow result + */ + async startUpgradeFlow(): Promise { + // Get subscriber information from account service + if (!this.subscriber) { + throw new Error("No active subscriber found for upgrade flow"); + } + // Start the upgrade dialog flow + while (true) { + // Open the upgrade account dialog + this.upgradeToPremiumDialogRef = UpgradeAccountDialogComponent.open(this.dialogService); + const dialogResult = await lastValueFrom(this.upgradeToPremiumDialogRef.closed); + // Clear the reference to the upgrade dialog + this.upgradeToPremiumDialogRef = undefined; + + if (!dialogResult) { + return UpgradeFlowResult.Cancelled; + } + + // If the dialog was closed without proceeding to payment + if (dialogResult.status !== UpgradeAccountDialogStatus.ProceededToPayment) { + return UpgradeFlowResult.Cancelled; + } + + // If user proceeded to payment + if (dialogResult.status === UpgradeAccountDialogStatus.ProceededToPayment) { + this.upgradePaymentDialogRef = UpgradePaymentDialogComponent.open(this.dialogService, { + data: { + plan: dialogResult.plan, + subscriber: this.subscriber, + } as UpgradePaymentDialogParams, + }); + const paymentResult = await lastValueFrom(this.upgradePaymentDialogRef.closed); + this.upgradePaymentDialogRef = undefined; + + if (!paymentResult) { + return UpgradeFlowResult.Cancelled; + } + + // If user clicked "Back", continue the loop to reopen the first dialog + if (paymentResult === UpgradePaymentDialogResult.Back) { + continue; + } + + // Handle successful payment outcomes + if (paymentResult === UpgradePaymentDialogResult.UpgradedToPremium) { + return UpgradeFlowResult.Upgraded; + } else if (paymentResult === UpgradePaymentDialogResult.UpgradedToFamilies) { + return UpgradeFlowResult.Upgraded; + } else { + return UpgradeFlowResult.Cancelled; + } + } + + // Exit the loop for all other cases + return UpgradeFlowResult.Cancelled; + } + } +}