1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-279699] Clear premium interest when user subscribes or closes dialog (#17221)

* Clear premium interest when user subscribes to premium or backs out of dialog

* Kyle's feedback
This commit is contained in:
Alex Morask
2025-11-11 10:51:46 -06:00
committed by GitHub
parent 3c1262c999
commit 09b6c35d9f
2 changed files with 205 additions and 6 deletions

View File

@@ -1,8 +1,10 @@
import { Component, input, output } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import {
PersonalSubscriptionPricingTierId,
@@ -58,6 +60,8 @@ describe("UnifiedUpgradeDialogComponent", () => {
let component: UnifiedUpgradeDialogComponent;
let fixture: ComponentFixture<UnifiedUpgradeDialogComponent>;
const mockDialogRef = mock<DialogRef>();
const mockRouter = mock<Router>();
const mockPremiumInterestStateService = mock<PremiumInterestStateService>();
const mockAccount: Account = {
id: "user-id" as UserId,
@@ -74,11 +78,16 @@ describe("UnifiedUpgradeDialogComponent", () => {
};
beforeEach(async () => {
// Reset mocks
jest.clearAllMocks();
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: defaultDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
@@ -121,6 +130,8 @@ describe("UnifiedUpgradeDialogComponent", () => {
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
@@ -161,6 +172,8 @@ describe("UnifiedUpgradeDialogComponent", () => {
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
@@ -191,11 +204,11 @@ describe("UnifiedUpgradeDialogComponent", () => {
});
describe("previousStep", () => {
it("should go back to plan selection and clear selected plan", () => {
it("should go back to plan selection and clear selected plan", async () => {
component["step"].set(UnifiedUpgradeDialogStep.Payment);
component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium);
component["previousStep"]();
await component["previousStep"]();
expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection);
expect(component["selectedPlan"]()).toBeNull();
@@ -222,6 +235,8 @@ describe("UnifiedUpgradeDialogComponent", () => {
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
@@ -241,4 +256,169 @@ describe("UnifiedUpgradeDialogComponent", () => {
expect(customComponent["hideContinueWithoutUpgradingButton"]()).toBe(true);
});
});
describe("onComplete with premium interest", () => {
it("should check premium interest, clear it, and route to /vault when premium interest exists", async () => {
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(true);
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue();
mockRouter.navigate.mockResolvedValue(true);
const result: UpgradePaymentResult = {
status: "upgradedToPremium",
organizationId: null,
};
await component["onComplete"](result);
expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockRouter.navigate).toHaveBeenCalledWith(["/vault"]);
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToPremium",
organizationId: null,
});
});
it("should not clear premium interest when upgrading to families", async () => {
const result: UpgradePaymentResult = {
status: "upgradedToFamilies",
organizationId: "org-123",
};
await component["onComplete"](result);
expect(mockPremiumInterestStateService.getPremiumInterest).not.toHaveBeenCalled();
expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled();
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToFamilies",
organizationId: "org-123",
});
});
it("should use standard redirect when no premium interest exists", async () => {
TestBed.resetTestingModule();
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
redirectOnCompletion: true,
};
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false);
mockRouter.navigate.mockResolvedValue(true);
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
remove: {
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
},
add: {
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
},
})
.compileComponents();
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
const customComponent = customFixture.componentInstance;
customFixture.detectChanges();
const result: UpgradePaymentResult = {
status: "upgradedToPremium",
organizationId: null,
};
await customComponent["onComplete"](result);
expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledWith([
"/settings/subscription/user-subscription",
]);
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToPremium",
organizationId: null,
});
});
});
describe("onCloseClicked with premium interest", () => {
it("should clear premium interest when modal is closed", async () => {
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue();
await component["onCloseClicked"]();
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
});
});
describe("previousStep with premium interest", () => {
it("should NOT clear premium interest when navigating between steps", async () => {
component["step"].set(UnifiedUpgradeDialogStep.Payment);
component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium);
await component["previousStep"]();
expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled();
expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection);
expect(component["selectedPlan"]()).toBeNull();
});
it("should clear premium interest when backing out of dialog completely", async () => {
TestBed.resetTestingModule();
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
initialStep: UnifiedUpgradeDialogStep.Payment,
selectedPlan: PersonalSubscriptionPricingTierIds.Premium,
};
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue();
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
remove: {
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
},
add: {
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
},
})
.compileComponents();
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
const customComponent = customFixture.componentInstance;
customFixture.detectChanges();
await customComponent["previousStep"]();
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
});
});
});

View File

@@ -3,6 +3,7 @@ import { CommonModule } from "@angular/common";
import { Component, Inject, OnInit, signal } from "@angular/core";
import { Router } from "@angular/router";
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { PersonalSubscriptionPricingTierId } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
@@ -94,6 +95,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
private dialogRef: DialogRef<UnifiedUpgradeDialogResult>,
@Inject(DIALOG_DATA) private params: UnifiedUpgradeDialogParams,
private router: Router,
private premiumInterestStateService: PremiumInterestStateService,
) {}
ngOnInit(): void {
@@ -110,7 +112,9 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
this.selectedPlan.set(planId);
this.nextStep();
}
protected onCloseClicked(): void {
protected async onCloseClicked(): Promise<void> {
// Clear premium interest when user closes/abandons modal
await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id);
this.close({ status: UnifiedUpgradeDialogStatus.Closed });
}
@@ -124,18 +128,20 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
}
}
protected previousStep(): void {
protected async previousStep(): Promise<void> {
// If we are on the payment step and there was no initial step, go back to plan selection this is to prevent
// going back to payment step if the dialog was opened directly to payment step
if (this.step() === UnifiedUpgradeDialogStep.Payment && this.params?.initialStep == null) {
this.step.set(UnifiedUpgradeDialogStep.PlanSelection);
this.selectedPlan.set(null);
} else {
// Clear premium interest when backing out of dialog completely
await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id);
this.close({ status: UnifiedUpgradeDialogStatus.Closed });
}
}
protected onComplete(result: UpgradePaymentResult): void {
protected async onComplete(result: UpgradePaymentResult): Promise<void> {
let status: UnifiedUpgradeDialogStatus;
switch (result.status) {
case "upgradedToPremium":
@@ -153,6 +159,19 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
this.close({ status, organizationId: result.organizationId });
// Check premium interest and route to vault for marketing-initiated premium upgrades
if (status === UnifiedUpgradeDialogStatus.UpgradedToPremium) {
const hasPremiumInterest = await this.premiumInterestStateService.getPremiumInterest(
this.params.account.id,
);
if (hasPremiumInterest) {
await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id);
await this.router.navigate(["/vault"]);
return; // Exit early, don't use redirectOnCompletion
}
}
// Use redirectOnCompletion for standard upgrade flows
if (
this.params.redirectOnCompletion &&
(status === UnifiedUpgradeDialogStatus.UpgradedToPremium ||
@@ -162,7 +181,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
status === UnifiedUpgradeDialogStatus.UpgradedToFamilies
? `/organizations/${result.organizationId}/vault`
: "/settings/subscription/user-subscription";
void this.router.navigate([redirectUrl]);
await this.router.navigate([redirectUrl]);
}
}