1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 14:23:32 +00:00

[PM-25379] Refactor org metadata (#16759)

* removing unused properties from org metadata

* removing further properties from the response and replacing them with data already available

* [PM-25379] new org metadata service for new endpoint

* don't need strict ignore

* forgot unit tests

* added cache busting to metadata service

not used yet - waiting for a decision on moving a portion of this to AC
This commit is contained in:
Kyle Denney
2025-10-13 10:49:52 -05:00
committed by GitHub
parent a7242a1186
commit 6ee41343a5
11 changed files with 404 additions and 34 deletions

View File

@@ -47,6 +47,7 @@ import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums"; import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -146,6 +147,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
private groupService: GroupApiService, private groupService: GroupApiService,
private collectionService: CollectionService, private collectionService: CollectionService,
private billingApiService: BillingApiServiceAbstraction, private billingApiService: BillingApiServiceAbstraction,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService, protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
private configService: ConfigService, private configService: ConfigService,
private organizationUserService: OrganizationUserService, private organizationUserService: OrganizationUserService,
@@ -257,7 +259,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
this.billingMetadata$ = combineLatest([this.refreshBillingMetadata$, organization$]).pipe( this.billingMetadata$ = combineLatest([this.refreshBillingMetadata$, organization$]).pipe(
switchMap(([_, organization]) => switchMap(([_, organization]) =>
this.billingApiService.getOrganizationBillingMetadata(organization.id), this.organizationMetadataService.getOrganizationMetadata$(organization.id),
), ),
takeUntilDestroyed(), takeUntilDestroyed(),
shareReplay({ bufferSize: 1, refCount: false }), shareReplay({ bufferSize: 1, refCount: false }),

View File

@@ -148,19 +148,17 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
const isResoldOrganizationOwner = this.userOrg.hasReseller && this.userOrg.isOwner; const isResoldOrganizationOwner = this.userOrg.hasReseller && this.userOrg.isOwner;
const isMSPUser = this.userOrg.hasProvider && this.userOrg.isProviderUser; const isMSPUser = this.userOrg.hasProvider && this.userOrg.isProviderUser;
const metadata = await this.billingApiService.getOrganizationBillingMetadata(
this.organizationId,
);
this.organizationIsManagedByConsolidatedBillingMSP = this.organizationIsManagedByConsolidatedBillingMSP =
this.userOrg.hasProvider && metadata.isManaged; this.userOrg.hasProvider && this.userOrg.hasBillableProvider;
this.showSubscription = this.showSubscription =
isIndependentOrganizationOwner || isIndependentOrganizationOwner ||
isResoldOrganizationOwner || isResoldOrganizationOwner ||
(isMSPUser && !this.organizationIsManagedByConsolidatedBillingMSP); (isMSPUser && !this.organizationIsManagedByConsolidatedBillingMSP);
this.showSelfHost = metadata.isEligibleForSelfHost; this.showSelfHost =
this.userOrg.productTierType === ProductTierType.Families ||
this.userOrg.productTierType === ProductTierType.Enterprise;
if (this.showSubscription) { if (this.showSubscription) {
this.sub = await this.organizationApiService.getSubscription(this.organizationId); this.sub = await this.organizationApiService.getSubscription(this.organizationId);

View File

@@ -9,6 +9,7 @@ import { BehaviorSubject, debounceTime, firstValueFrom, lastValueFrom } from "rx
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid"; import { OrganizationId } from "@bitwarden/common/types/guid";
@@ -56,6 +57,7 @@ export class MemberAccessReportComponent implements OnInit {
protected dialogService: DialogService, protected dialogService: DialogService,
protected userNamePipe: UserNamePipe, protected userNamePipe: UserNamePipe,
protected billingApiService: BillingApiServiceAbstraction, protected billingApiService: BillingApiServiceAbstraction,
protected organizationMetadataService: OrganizationMetadataServiceAbstraction,
) { ) {
// Connect the search input to the table dataSource filter input // Connect the search input to the table dataSource filter input
this.searchControl.valueChanges this.searchControl.valueChanges
@@ -69,8 +71,8 @@ export class MemberAccessReportComponent implements OnInit {
const params = await firstValueFrom(this.route.params); const params = await firstValueFrom(this.route.params);
this.organizationId = params.organizationId; this.organizationId = params.organizationId;
const billingMetadata = await this.billingApiService.getOrganizationBillingMetadata( const billingMetadata = await firstValueFrom(
this.organizationId, this.organizationMetadataService.getOrganizationMetadata$(this.organizationId),
); );
this.orgIsOnSecretsManagerStandalone = billingMetadata.isOnSecretsManagerStandalone; this.orgIsOnSecretsManagerStandalone = billingMetadata.isOnSecretsManagerStandalone;

View File

@@ -145,12 +145,14 @@ import {
} from "@bitwarden/common/billing/abstractions"; } from "@bitwarden/common/billing/abstractions";
import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/account/account-billing-api.service.abstraction"; import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/account/account-billing-api.service.abstraction";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction"; import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service"; import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service";
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service"; import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service";
import { DefaultOrganizationMetadataService } from "@bitwarden/common/billing/services/organization/organization-metadata.service";
import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service"; import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service";
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service"; import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service";
@@ -1414,6 +1416,11 @@ const safeProviders: SafeProvider[] = [
useClass: BillingApiService, useClass: BillingApiService,
deps: [ApiServiceAbstraction], deps: [ApiServiceAbstraction],
}), }),
safeProvider({
provide: OrganizationMetadataServiceAbstraction,
useClass: DefaultOrganizationMetadataService,
deps: [BillingApiServiceAbstraction, ConfigService],
}),
safeProvider({ safeProvider({
provide: BillingAccountProfileStateService, provide: BillingAccountProfileStateService,
useClass: DefaultBillingAccountProfileStateService, useClass: DefaultBillingAccountProfileStateService,

View File

@@ -4,6 +4,7 @@ import { SubscriptionCancellationRequest } from "../../billing/models/request/su
import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response"; import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response";
import { PlanResponse } from "../../billing/models/response/plan.response"; import { PlanResponse } from "../../billing/models/response/plan.response";
import { ListResponse } from "../../models/response/list.response"; import { ListResponse } from "../../models/response/list.response";
import { OrganizationId } from "../../types/guid";
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request"; import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request"; import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request";
import { InvoicesResponse } from "../models/response/invoices.response"; import { InvoicesResponse } from "../models/response/invoices.response";
@@ -23,7 +24,11 @@ export abstract class BillingApiServiceAbstraction {
): Promise<void>; ): Promise<void>;
abstract getOrganizationBillingMetadata( abstract getOrganizationBillingMetadata(
organizationId: string, organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse>;
abstract getOrganizationBillingMetadataVNext(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse>; ): Promise<OrganizationBillingMetadataResponse>;
abstract getPlans(): Promise<ListResponse<PlanResponse>>; abstract getPlans(): Promise<ListResponse<PlanResponse>>;

View File

@@ -0,0 +1,12 @@
import { Observable } from "rxjs";
import { OrganizationId } from "../../types/guid";
import { OrganizationBillingMetadataResponse } from "../models/response/organization-billing-metadata.response";
export abstract class OrganizationMetadataServiceAbstraction {
abstract getOrganizationMetadata$(
organizationId: OrganizationId,
): Observable<OrganizationBillingMetadataResponse>;
abstract refreshMetadataCache(): void;
}

View File

@@ -1,35 +1,12 @@
import { BaseResponse } from "../../../models/response/base.response"; import { BaseResponse } from "../../../models/response/base.response";
export class OrganizationBillingMetadataResponse extends BaseResponse { export class OrganizationBillingMetadataResponse extends BaseResponse {
isEligibleForSelfHost: boolean;
isManaged: boolean;
isOnSecretsManagerStandalone: boolean; isOnSecretsManagerStandalone: boolean;
isSubscriptionUnpaid: boolean;
hasSubscription: boolean;
hasOpenInvoice: boolean;
invoiceDueDate: Date | null;
invoiceCreatedDate: Date | null;
subPeriodEndDate: Date | null;
isSubscriptionCanceled: boolean;
organizationOccupiedSeats: number; organizationOccupiedSeats: number;
constructor(response: any) { constructor(response: any) {
super(response); super(response);
this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost");
this.isManaged = this.getResponseProperty("IsManaged");
this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone"); this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone");
this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid");
this.hasSubscription = this.getResponseProperty("HasSubscription");
this.hasOpenInvoice = this.getResponseProperty("HasOpenInvoice");
this.invoiceDueDate = this.parseDate(this.getResponseProperty("InvoiceDueDate"));
this.invoiceCreatedDate = this.parseDate(this.getResponseProperty("InvoiceCreatedDate"));
this.subPeriodEndDate = this.parseDate(this.getResponseProperty("SubPeriodEndDate"));
this.isSubscriptionCanceled = this.getResponseProperty("IsSubscriptionCanceled");
this.organizationOccupiedSeats = this.getResponseProperty("OrganizationOccupiedSeats"); this.organizationOccupiedSeats = this.getResponseProperty("OrganizationOccupiedSeats");
} }
private parseDate(dateString: any): Date | null {
return dateString ? new Date(dateString) : null;
}
} }

View File

@@ -5,6 +5,7 @@ import { ApiService } from "../../abstractions/api.service";
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response"; import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response";
import { ListResponse } from "../../models/response/list.response"; import { ListResponse } from "../../models/response/list.response";
import { OrganizationId } from "../../types/guid";
import { BillingApiServiceAbstraction } from "../abstractions"; import { BillingApiServiceAbstraction } from "../abstractions";
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request"; import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
import { SubscriptionCancellationRequest } from "../models/request/subscription-cancellation.request"; import { SubscriptionCancellationRequest } from "../models/request/subscription-cancellation.request";
@@ -48,7 +49,7 @@ export class BillingApiService implements BillingApiServiceAbstraction {
} }
async getOrganizationBillingMetadata( async getOrganizationBillingMetadata(
organizationId: string, organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse> { ): Promise<OrganizationBillingMetadataResponse> {
const r = await this.apiService.send( const r = await this.apiService.send(
"GET", "GET",
@@ -61,6 +62,20 @@ export class BillingApiService implements BillingApiServiceAbstraction {
return new OrganizationBillingMetadataResponse(r); return new OrganizationBillingMetadataResponse(r);
} }
async getOrganizationBillingMetadataVNext(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse> {
const r = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/billing/vnext/metadata",
null,
true,
true,
);
return new OrganizationBillingMetadataResponse(r);
}
async getPlans(): Promise<ListResponse<PlanResponse>> { async getPlans(): Promise<ListResponse<PlanResponse>> {
const r = await this.apiService.send("GET", "/plans", null, false, true); const r = await this.apiService.send("GET", "/plans", null, false, true);
return new ListResponse(r, PlanResponse); return new ListResponse(r, PlanResponse);

View File

@@ -0,0 +1,276 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { newGuid } from "@bitwarden/guid";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { OrganizationId } from "../../../types/guid";
import { DefaultOrganizationMetadataService } from "./organization-metadata.service";
describe("DefaultOrganizationMetadataService", () => {
let service: DefaultOrganizationMetadataService;
let billingApiService: jest.Mocked<BillingApiServiceAbstraction>;
let configService: jest.Mocked<ConfigService>;
let featureFlagSubject: BehaviorSubject<boolean>;
const mockOrganizationId = newGuid() as OrganizationId;
const mockOrganizationId2 = newGuid() as OrganizationId;
const createMockMetadataResponse = (
isOnSecretsManagerStandalone = false,
organizationOccupiedSeats = 5,
): OrganizationBillingMetadataResponse => {
return {
isOnSecretsManagerStandalone,
organizationOccupiedSeats,
} as OrganizationBillingMetadataResponse;
};
beforeEach(() => {
billingApiService = mock<BillingApiServiceAbstraction>();
configService = mock<ConfigService>();
featureFlagSubject = new BehaviorSubject<boolean>(false);
configService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable());
service = new DefaultOrganizationMetadataService(billingApiService, configService);
});
afterEach(() => {
jest.resetAllMocks();
featureFlagSubject.complete();
});
describe("getOrganizationMetadata$", () => {
describe("feature flag OFF", () => {
beforeEach(() => {
featureFlagSubject.next(false);
});
it("calls getOrganizationBillingMetadata when feature flag is off", async () => {
const mockResponse = createMockMetadataResponse(false, 10);
billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse);
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM25379_UseNewOrganizationMetadataStructure,
);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledWith(
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled();
expect(result).toEqual(mockResponse);
});
it("does not cache metadata when feature flag is off", async () => {
const mockResponse1 = createMockMetadataResponse(false, 10);
const mockResponse2 = createMockMetadataResponse(false, 15);
billingApiService.getOrganizationBillingMetadata
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
expect(result1).toEqual(mockResponse1);
expect(result2).toEqual(mockResponse2);
});
});
describe("feature flag ON", () => {
beforeEach(() => {
featureFlagSubject.next(true);
});
it("calls getOrganizationBillingMetadataVNext when feature flag is on", async () => {
const mockResponse = createMockMetadataResponse(true, 15);
billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM25379_UseNewOrganizationMetadataStructure,
);
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledWith(
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled();
expect(result).toEqual(mockResponse);
});
it("caches metadata by organization ID when feature flag is on", async () => {
const mockResponse = createMockMetadataResponse(true, 10);
billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1);
expect(result1).toEqual(mockResponse);
expect(result2).toEqual(mockResponse);
});
it("maintains separate cache entries for different organization IDs", async () => {
const mockResponse1 = createMockMetadataResponse(true, 10);
const mockResponse2 = createMockMetadataResponse(false, 20);
billingApiService.getOrganizationBillingMetadataVNext
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2));
const result3 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result4 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2));
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2);
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith(
1,
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith(
2,
mockOrganizationId2,
);
expect(result1).toEqual(mockResponse1);
expect(result2).toEqual(mockResponse2);
expect(result3).toEqual(mockResponse1);
expect(result4).toEqual(mockResponse2);
});
});
describe("shareReplay behavior", () => {
beforeEach(() => {
featureFlagSubject.next(true);
});
it("does not call API multiple times when the same cached observable is subscribed to multiple times", async () => {
const mockResponse = createMockMetadataResponse(true, 10);
billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
const metadata$ = service.getOrganizationMetadata$(mockOrganizationId);
const subscription1Promise = firstValueFrom(metadata$);
const subscription2Promise = firstValueFrom(metadata$);
const subscription3Promise = firstValueFrom(metadata$);
const [result1, result2, result3] = await Promise.all([
subscription1Promise,
subscription2Promise,
subscription3Promise,
]);
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1);
expect(result1).toEqual(mockResponse);
expect(result2).toEqual(mockResponse);
expect(result3).toEqual(mockResponse);
});
});
});
describe("refreshMetadataCache", () => {
beforeEach(() => {
featureFlagSubject.next(true);
});
it("refreshes cached metadata when called with feature flag on", (done) => {
const mockResponse1 = createMockMetadataResponse(true, 10);
const mockResponse2 = createMockMetadataResponse(true, 20);
let invocationCount = 0;
billingApiService.getOrganizationBillingMetadataVNext
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({
next: (result) => {
invocationCount++;
if (invocationCount === 1) {
expect(result).toEqual(mockResponse1);
} else if (invocationCount === 2) {
expect(result).toEqual(mockResponse2);
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2);
subscription.unsubscribe();
done();
}
},
error: done.fail,
});
setTimeout(() => {
service.refreshMetadataCache();
}, 10);
});
it("does not trigger refresh when feature flag is disabled", async () => {
featureFlagSubject.next(false);
const mockResponse1 = createMockMetadataResponse(false, 10);
const mockResponse2 = createMockMetadataResponse(false, 20);
let invocationCount = 0;
billingApiService.getOrganizationBillingMetadata
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({
next: () => {
invocationCount++;
},
});
// wait for initial invocation
await new Promise((resolve) => setTimeout(resolve, 10));
expect(invocationCount).toBe(1);
service.refreshMetadataCache();
// wait to ensure no additional invocations
await new Promise((resolve) => setTimeout(resolve, 10));
expect(invocationCount).toBe(1);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1);
subscription.unsubscribe();
});
it("bypasses cache when refreshing metadata", (done) => {
const mockResponse1 = createMockMetadataResponse(true, 10);
const mockResponse2 = createMockMetadataResponse(true, 20);
const mockResponse3 = createMockMetadataResponse(true, 30);
let invocationCount = 0;
billingApiService.getOrganizationBillingMetadataVNext
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2)
.mockResolvedValueOnce(mockResponse3);
const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({
next: (result) => {
invocationCount++;
if (invocationCount === 1) {
expect(result).toEqual(mockResponse1);
service.refreshMetadataCache();
} else if (invocationCount === 2) {
expect(result).toEqual(mockResponse2);
service.refreshMetadataCache();
} else if (invocationCount === 3) {
expect(result).toEqual(mockResponse3);
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(3);
subscription.unsubscribe();
done();
}
},
error: done.fail,
});
});
});
});

View File

@@ -0,0 +1,74 @@
import { filter, from, merge, Observable, shareReplay, Subject, switchMap } from "rxjs";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { OrganizationId } from "../../../types/guid";
import { OrganizationMetadataServiceAbstraction } from "../../abstractions/organization-metadata.service.abstraction";
import { OrganizationBillingMetadataResponse } from "../../models/response/organization-billing-metadata.response";
export class DefaultOrganizationMetadataService implements OrganizationMetadataServiceAbstraction {
private metadataCache = new Map<
OrganizationId,
Observable<OrganizationBillingMetadataResponse>
>();
constructor(
private billingApiService: BillingApiServiceAbstraction,
private configService: ConfigService,
) {}
private refreshMetadataTrigger = new Subject<void>();
refreshMetadataCache = () => this.refreshMetadataTrigger.next();
getOrganizationMetadata$ = (
organizationId: OrganizationId,
): Observable<OrganizationBillingMetadataResponse> =>
this.configService
.getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure)
.pipe(
switchMap((featureFlagEnabled) => {
return merge(
this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled),
this.refreshMetadataTrigger.pipe(
filter(() => featureFlagEnabled),
switchMap(() =>
this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled, true),
),
),
);
}),
);
private getOrganizationMetadataInternal$(
organizationId: OrganizationId,
featureFlagEnabled: boolean,
bypassCache: boolean = false,
): Observable<OrganizationBillingMetadataResponse> {
if (!bypassCache && featureFlagEnabled && this.metadataCache.has(organizationId)) {
return this.metadataCache.get(organizationId)!;
}
const metadata$ = from(this.fetchMetadata(organizationId, featureFlagEnabled)).pipe(
shareReplay({ bufferSize: 1, refCount: false }),
);
if (featureFlagEnabled) {
this.metadataCache.set(organizationId, metadata$);
}
return metadata$;
}
private async fetchMetadata(
organizationId: OrganizationId,
featureFlagEnabled: boolean,
): Promise<OrganizationBillingMetadataResponse> {
if (featureFlagEnabled) {
return await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId);
}
return await this.billingApiService.getOrganizationBillingMetadata(organizationId);
}
}

View File

@@ -25,6 +25,7 @@ export enum FeatureFlag {
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships", PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover", PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings", PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog", PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
/* Key Management */ /* Key Management */
@@ -100,6 +101,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE, [FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE, [FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE, [FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
/* Key Management */ /* Key Management */