1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 22:33:35 +00:00

[PM-23713] premium badge interaction (#16911)

* feature flag

* new upgrade dialog component and moved pricing service into libs

first draft

* moved pricing service to libs/common

removed toast service from the pricing service and implemented error handling in calling components

# Conflicts:
#	apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts

* moved new premium upgrade dialog component to libs/angular

* badge opens new dialog in browser extension

* adds new dialog to desktop and fixes tests

* updates send dropdown to use premium prompt service

* styling and copy updates

* implement in web and desktop

* unit tests

* converting premium reports to use premium badge, and some cleanup

* fixes issue after merge

* linter errors

* pr feedback

* handle async promise correctly

* full sync after the premium upgrade is complete

* fixing test

* add padding to bottom of card in new dialog

* add support for self hosting

* fixing tests

* fix test

* Update has-premium.guard.ts

* pr feedback

* fix build and pr feedback

* fix build

* prettier

* fixing stories and making badge line height consistent

* pr feedback

* updated upgrade dialog to no longer use pricing card

* fixing incorrect markup and removing unused bits

* formatting

* pr feedback

removing unused message keys and adding back in code that was erroneously removed

* change detection

* close dialog when error

* claude pr feedback
This commit is contained in:
Kyle Denney
2025-11-03 10:16:01 -06:00
committed by GitHub
parent 3c16547f11
commit e1e3966cc2
55 changed files with 1462 additions and 355 deletions

View File

@@ -0,0 +1,240 @@
import { CdkTrapFocus } from "@angular/cdk/a11y";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { firstValueFrom, of, throwError } from "rxjs";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import {
EnvironmentService,
Region,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogRef, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component";
describe("PremiumUpgradeDialogComponent", () => {
let component: PremiumUpgradeDialogComponent;
let fixture: ComponentFixture<PremiumUpgradeDialogComponent>;
let mockDialogRef: jest.Mocked<DialogRef>;
let mockSubscriptionPricingService: jest.Mocked<SubscriptionPricingServiceAbstraction>;
let mockI18nService: jest.Mocked<I18nService>;
let mockToastService: jest.Mocked<ToastService>;
let mockEnvironmentService: jest.Mocked<EnvironmentService>;
let mockPlatformUtilsService: jest.Mocked<PlatformUtilsService>;
let mockLogService: jest.Mocked<LogService>;
const mockPremiumTier: PersonalSubscriptionPricingTier = {
id: PersonalSubscriptionPricingTierIds.Premium,
name: "Premium",
description: "Advanced features for power users",
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
annualPrice: 10,
annualPricePerAdditionalStorageGB: 4,
features: [
{ key: "feature1", value: "Feature 1" },
{ key: "feature2", value: "Feature 2" },
{ key: "feature3", value: "Feature 3" },
],
},
};
const mockFamiliesTier: PersonalSubscriptionPricingTier = {
id: PersonalSubscriptionPricingTierIds.Families,
name: "Families",
description: "Family plan",
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "packaged",
users: 6,
annualPrice: 40,
annualPricePerAdditionalStorageGB: 4,
features: [{ key: "featureA", value: "Feature A" }],
},
};
beforeEach(async () => {
mockDialogRef = {
close: jest.fn(),
} as any;
mockSubscriptionPricingService = {
getPersonalSubscriptionPricingTiers$: jest.fn(),
} as any;
mockI18nService = {
t: jest.fn((key: string) => key),
} as any;
mockToastService = {
showToast: jest.fn(),
} as any;
mockEnvironmentService = {
environment$: of({
getWebVaultUrl: () => "https://vault.bitwarden.com",
getRegion: () => Region.US,
}),
} as any;
mockPlatformUtilsService = {
launchUri: jest.fn(),
} as any;
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
of([mockPremiumTier, mockFamiliesTier]),
);
mockLogService = {
error: jest.fn(),
} as any;
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, PremiumUpgradeDialogComponent, CdkTrapFocus],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{
provide: SubscriptionPricingServiceAbstraction,
useValue: mockSubscriptionPricingService,
},
{ provide: I18nService, useValue: mockI18nService },
{ provide: ToastService, useValue: mockToastService },
{ provide: EnvironmentService, useValue: mockEnvironmentService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: LogService, useValue: mockLogService },
],
}).compileComponents();
fixture = TestBed.createComponent(PremiumUpgradeDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should emit cardDetails$ observable with Premium tier data", async () => {
const cardDetails = await firstValueFrom(component["cardDetails$"]);
expect(mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$).toHaveBeenCalled();
expect(cardDetails).toBeDefined();
expect(cardDetails?.title).toBe("Premium");
});
it("should filter to Premium tier only", async () => {
const cardDetails = await firstValueFrom(component["cardDetails$"]);
expect(cardDetails?.title).toBe("Premium");
expect(cardDetails?.title).not.toBe("Families");
});
it("should map Premium tier to card details correctly", async () => {
const cardDetails = await firstValueFrom(component["cardDetails$"]);
expect(cardDetails?.title).toBe("Premium");
expect(cardDetails?.tagline).toBe("Advanced features for power users");
expect(cardDetails?.price.amount).toBe(10 / 12);
expect(cardDetails?.price.cadence).toBe("monthly");
expect(cardDetails?.button.text).toBe("upgradeNow");
expect(cardDetails?.button.type).toBe("primary");
expect(cardDetails?.features).toEqual(["Feature 1", "Feature 2", "Feature 3"]);
});
it("should use i18nService for button text", async () => {
const cardDetails = await firstValueFrom(component["cardDetails$"]);
expect(mockI18nService.t).toHaveBeenCalledWith("upgradeNow");
expect(cardDetails?.button.text).toBe("upgradeNow");
});
describe("upgrade()", () => {
it("should launch URI with query parameter for cloud-hosted environments", async () => {
mockEnvironmentService.environment$ = of({
getWebVaultUrl: () => "https://vault.bitwarden.com",
getRegion: () => Region.US,
} as any);
await component["upgrade"]();
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
"https://vault.bitwarden.com/#/settings/subscription/premium?callToAction=upgradeToPremium",
);
expect(mockDialogRef.close).toHaveBeenCalled();
});
it("should launch URI without query parameter for self-hosted environments", async () => {
mockEnvironmentService.environment$ = of({
getWebVaultUrl: () => "https://self-hosted.example.com",
getRegion: () => Region.SelfHosted,
} as any);
await component["upgrade"]();
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
"https://self-hosted.example.com/#/settings/subscription/premium",
);
expect(mockDialogRef.close).toHaveBeenCalled();
});
it("should launch URI with query parameter for EU cloud region", async () => {
mockEnvironmentService.environment$ = of({
getWebVaultUrl: () => "https://vault.bitwarden.eu",
getRegion: () => Region.EU,
} as any);
await component["upgrade"]();
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
"https://vault.bitwarden.eu/#/settings/subscription/premium?callToAction=upgradeToPremium",
);
expect(mockDialogRef.close).toHaveBeenCalled();
});
});
it("should close dialog when close button clicked", () => {
component["close"]();
expect(mockDialogRef.close).toHaveBeenCalled();
});
describe("error handling", () => {
it("should show error toast and return EMPTY and close dialog when getPersonalSubscriptionPricingTiers$ throws an error", (done) => {
const error = new Error("Service error");
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
throwError(() => error),
);
const errorFixture = TestBed.createComponent(PremiumUpgradeDialogComponent);
const errorComponent = errorFixture.componentInstance;
errorFixture.detectChanges();
const cardDetails$ = errorComponent["cardDetails$"];
cardDetails$.subscribe({
next: () => {
done.fail("Observable should not emit any values");
},
complete: () => {
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "error",
message: "unexpectedError",
});
expect(mockDialogRef.close).toHaveBeenCalled();
done();
},
error: (err: unknown) => done.fail(`Observable should not error: ${err}`),
});
});
});
});