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

(Billing) [PM-27562] Create PremiumInterestStateService (#17139)

Creates a `PremiumInterestStateService` that manages state which conveys whether or not a user intends to setup a premium subscription. Implemented in Web only. No-op for other clients.

This will apply for users who began the registration process on https://bitwarden.com/go/start-premium/, which is a marketing page designed to streamline users who intend to setup a premium subscription after registration.
This commit is contained in:
rr-bw
2025-11-03 14:42:21 -08:00
committed by GitHub
parent 906ac95175
commit 5c33b2dc89
7 changed files with 234 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
import { firstValueFrom } from "rxjs";
import {
FakeAccountService,
FakeStateProvider,
mockAccountServiceWith,
} from "@bitwarden/common/spec";
import { newGuid } from "@bitwarden/guid";
import { UserId } from "@bitwarden/user-core";
import {
PREMIUM_INTEREST_KEY,
WebPremiumInterestStateService,
} from "./web-premium-interest-state.service";
describe("WebPremiumInterestStateService", () => {
let service: WebPremiumInterestStateService;
let stateProvider: FakeStateProvider;
let accountService: FakeAccountService;
const mockUserId = newGuid() as UserId;
const mockUserEmail = "user@example.com";
beforeEach(() => {
accountService = mockAccountServiceWith(mockUserId, { email: mockUserEmail });
stateProvider = new FakeStateProvider(accountService);
service = new WebPremiumInterestStateService(stateProvider);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("getPremiumInterest", () => {
it("should throw an error when userId is not provided", async () => {
const promise = service.getPremiumInterest(null);
await expect(promise).rejects.toThrow("UserId is required. Cannot get 'premiumInterest'.");
});
it("should return null when no value is set", async () => {
const result = await service.getPremiumInterest(mockUserId);
expect(result).toBeNull();
});
it("should return true when value is set to true", async () => {
await stateProvider.setUserState(PREMIUM_INTEREST_KEY, true, mockUserId);
const result = await service.getPremiumInterest(mockUserId);
expect(result).toBe(true);
});
it("should return false when value is set to false", async () => {
await stateProvider.setUserState(PREMIUM_INTEREST_KEY, false, mockUserId);
const result = await service.getPremiumInterest(mockUserId);
expect(result).toBe(false);
});
it("should use getUserState$ to retrieve the value", async () => {
const getUserStateSpy = jest.spyOn(stateProvider, "getUserState$");
await stateProvider.setUserState(PREMIUM_INTEREST_KEY, true, mockUserId);
await service.getPremiumInterest(mockUserId);
expect(getUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, mockUserId);
});
});
describe("setPremiumInterest", () => {
it("should throw an error when userId is not provided", async () => {
const promise = service.setPremiumInterest(null, true);
await expect(promise).rejects.toThrow("UserId is required. Cannot set 'premiumInterest'.");
});
it("should set the value to true", async () => {
await service.setPremiumInterest(mockUserId, true);
const result = await firstValueFrom(
stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId),
);
expect(result).toBe(true);
});
it("should set the value to false", async () => {
await service.setPremiumInterest(mockUserId, false);
const result = await firstValueFrom(
stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId),
);
expect(result).toBe(false);
});
it("should update an existing value", async () => {
await service.setPremiumInterest(mockUserId, true);
await service.setPremiumInterest(mockUserId, false);
const result = await firstValueFrom(
stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId),
);
expect(result).toBe(false);
});
it("should use setUserState to store the value", async () => {
const setUserStateSpy = jest.spyOn(stateProvider, "setUserState");
await service.setPremiumInterest(mockUserId, true);
expect(setUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, true, mockUserId);
});
});
describe("clearPremiumInterest", () => {
it("should throw an error when userId is not provided", async () => {
const promise = service.clearPremiumInterest(null);
await expect(promise).rejects.toThrow("UserId is required. Cannot clear 'premiumInterest'.");
});
it("should clear the value by setting it to null", async () => {
await service.setPremiumInterest(mockUserId, true);
await service.clearPremiumInterest(mockUserId);
const result = await firstValueFrom(
stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId),
);
expect(result).toBeNull();
});
it("should use setUserState with null to clear the value", async () => {
const setUserStateSpy = jest.spyOn(stateProvider, "setUserState");
await service.setPremiumInterest(mockUserId, true);
await service.clearPremiumInterest(mockUserId);
expect(setUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, null, mockUserId);
});
});
});

View File

@@ -0,0 +1,44 @@
import { Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
import { BILLING_MEMORY, StateProvider, UserKeyDefinition } from "@bitwarden/state";
import { UserId } from "@bitwarden/user-core";
export const PREMIUM_INTEREST_KEY = new UserKeyDefinition<boolean>(
BILLING_MEMORY,
"premiumInterest",
{
deserializer: (value: boolean) => value,
clearOn: ["lock", "logout"],
},
);
@Injectable()
export class WebPremiumInterestStateService implements PremiumInterestStateService {
constructor(private stateProvider: StateProvider) {}
async getPremiumInterest(userId: UserId): Promise<boolean | null> {
if (!userId) {
throw new Error("UserId is required. Cannot get 'premiumInterest'.");
}
return await firstValueFrom(this.stateProvider.getUserState$(PREMIUM_INTEREST_KEY, userId));
}
async setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise<void> {
if (!userId) {
throw new Error("UserId is required. Cannot set 'premiumInterest'.");
}
await this.stateProvider.setUserState(PREMIUM_INTEREST_KEY, premiumInterest, userId);
}
async clearPremiumInterest(userId: UserId): Promise<void> {
if (!userId) {
throw new Error("UserId is required. Cannot clear 'premiumInterest'.");
}
await this.stateProvider.setUserState(PREMIUM_INTEREST_KEY, null, userId);
}
}

View File

@@ -14,6 +14,7 @@ import { DefaultDeviceManagementComponentService } from "@bitwarden/angular/auth
import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction";
import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password";
import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction";
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import {
CLIENT_TYPE,
@@ -129,6 +130,7 @@ import {
WebSetInitialPasswordService,
} from "../auth";
import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service";
import { WebPremiumInterestStateService } from "../billing/services/premium-interest/web-premium-interest-state.service";
import { HtmlStorageService } from "../core/html-storage.service";
import { I18nService } from "../core/i18n.service";
import { WebFileDownloadService } from "../core/web-file-download.service";
@@ -421,6 +423,11 @@ const safeProviders: SafeProvider[] = [
Router,
],
}),
safeProvider({
provide: PremiumInterestStateService,
useClass: WebPremiumInterestStateService,
deps: [StateProvider],
}),
];
@NgModule({

View File

@@ -0,0 +1,14 @@
import { Injectable } from "@angular/core";
import { UserId } from "@bitwarden/user-core";
import { PremiumInterestStateService } from "./premium-interest-state.service.abstraction";
@Injectable()
export class NoopPremiumInterestStateService implements PremiumInterestStateService {
async getPremiumInterest(userId: UserId): Promise<boolean | null> {
return null;
} // no-op
async setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise<void> {} // no-op
async clearPremiumInterest(userId: UserId): Promise<void> {} // no-op
}

View File

@@ -0,0 +1,14 @@
import { UserId } from "@bitwarden/user-core";
/**
* A service that manages state which conveys whether or not a user has expressed interest
* in setting up a premium subscription. This applies for users who began the registration
* process on https://bitwarden.com/go/start-premium/, which is a marketing page designed
* to streamline users who intend to setup a premium subscription after registration.
* - Implemented in Web only. No-op for other clients.
*/
export abstract class PremiumInterestStateService {
abstract getPremiumInterest(userId: UserId): Promise<boolean | null>;
abstract setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise<void>;
abstract clearPremiumInterest(userId: UserId): Promise<void>;
}

View File

@@ -380,6 +380,8 @@ import { DefaultSetInitialPasswordService } from "../auth/password-management/se
import { SetInitialPasswordService } from "../auth/password-management/set-initial-password/set-initial-password.service.abstraction";
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
import { NoopPremiumInterestStateService } from "../billing/services/premium-interest/noop-premium-interest-state.service";
import { PremiumInterestStateService } from "../billing/services/premium-interest/premium-interest-state.service.abstraction";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { DocumentLangSetter } from "../platform/i18n";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
@@ -1724,6 +1726,11 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultNewDeviceVerificationComponentService,
deps: [],
}),
safeProvider({
provide: PremiumInterestStateService,
useClass: NoopPremiumInterestStateService,
deps: [],
}),
];
@NgModule({

View File

@@ -40,6 +40,7 @@ export const AUTO_CONFIRM = new StateDefinition("autoConfirm", "disk");
// Billing
export const BILLING_DISK = new StateDefinition("billing", "disk");
export const BILLING_MEMORY = new StateDefinition("billing", "memory");
// Auth