mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +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:
@@ -47,6 +47,7 @@ import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
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 { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
@@ -146,6 +147,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
private groupService: GroupApiService,
|
||||
private collectionService: CollectionService,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
|
||||
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
|
||||
private configService: ConfigService,
|
||||
private organizationUserService: OrganizationUserService,
|
||||
@@ -257,7 +259,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
|
||||
this.billingMetadata$ = combineLatest([this.refreshBillingMetadata$, organization$]).pipe(
|
||||
switchMap(([_, organization]) =>
|
||||
this.billingApiService.getOrganizationBillingMetadata(organization.id),
|
||||
this.organizationMetadataService.getOrganizationMetadata$(organization.id),
|
||||
),
|
||||
takeUntilDestroyed(),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
|
||||
@@ -148,19 +148,17 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
|
||||
const isResoldOrganizationOwner = this.userOrg.hasReseller && this.userOrg.isOwner;
|
||||
const isMSPUser = this.userOrg.hasProvider && this.userOrg.isProviderUser;
|
||||
|
||||
const metadata = await this.billingApiService.getOrganizationBillingMetadata(
|
||||
this.organizationId,
|
||||
);
|
||||
|
||||
this.organizationIsManagedByConsolidatedBillingMSP =
|
||||
this.userOrg.hasProvider && metadata.isManaged;
|
||||
this.userOrg.hasProvider && this.userOrg.hasBillableProvider;
|
||||
|
||||
this.showSubscription =
|
||||
isIndependentOrganizationOwner ||
|
||||
isResoldOrganizationOwner ||
|
||||
(isMSPUser && !this.organizationIsManagedByConsolidatedBillingMSP);
|
||||
|
||||
this.showSelfHost = metadata.isEligibleForSelfHost;
|
||||
this.showSelfHost =
|
||||
this.userOrg.productTierType === ProductTierType.Families ||
|
||||
this.userOrg.productTierType === ProductTierType.Enterprise;
|
||||
|
||||
if (this.showSubscription) {
|
||||
this.sub = await this.organizationApiService.getSubscription(this.organizationId);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BehaviorSubject, debounceTime, firstValueFrom, lastValueFrom } from "rx
|
||||
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
|
||||
import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
|
||||
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
@@ -56,6 +57,7 @@ export class MemberAccessReportComponent implements OnInit {
|
||||
protected dialogService: DialogService,
|
||||
protected userNamePipe: UserNamePipe,
|
||||
protected billingApiService: BillingApiServiceAbstraction,
|
||||
protected organizationMetadataService: OrganizationMetadataServiceAbstraction,
|
||||
) {
|
||||
// Connect the search input to the table dataSource filter input
|
||||
this.searchControl.valueChanges
|
||||
@@ -69,8 +71,8 @@ export class MemberAccessReportComponent implements OnInit {
|
||||
const params = await firstValueFrom(this.route.params);
|
||||
this.organizationId = params.organizationId;
|
||||
|
||||
const billingMetadata = await this.billingApiService.getOrganizationBillingMetadata(
|
||||
this.organizationId,
|
||||
const billingMetadata = await firstValueFrom(
|
||||
this.organizationMetadataService.getOrganizationMetadata$(this.organizationId),
|
||||
);
|
||||
|
||||
this.orgIsOnSecretsManagerStandalone = billingMetadata.isOnSecretsManagerStandalone;
|
||||
|
||||
@@ -145,12 +145,14 @@ import {
|
||||
} from "@bitwarden/common/billing/abstractions";
|
||||
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 { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.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 { 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 { BillingApiService } from "@bitwarden/common/billing/services/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 { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
|
||||
import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service";
|
||||
@@ -1414,6 +1416,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: BillingApiService,
|
||||
deps: [ApiServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OrganizationMetadataServiceAbstraction,
|
||||
useClass: DefaultOrganizationMetadataService,
|
||||
deps: [BillingApiServiceAbstraction, ConfigService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: BillingAccountProfileStateService,
|
||||
useClass: DefaultBillingAccountProfileStateService,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SubscriptionCancellationRequest } from "../../billing/models/request/su
|
||||
import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response";
|
||||
import { PlanResponse } from "../../billing/models/response/plan.response";
|
||||
import { ListResponse } from "../../models/response/list.response";
|
||||
import { OrganizationId } from "../../types/guid";
|
||||
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
|
||||
import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request";
|
||||
import { InvoicesResponse } from "../models/response/invoices.response";
|
||||
@@ -23,7 +24,11 @@ export abstract class BillingApiServiceAbstraction {
|
||||
): Promise<void>;
|
||||
|
||||
abstract getOrganizationBillingMetadata(
|
||||
organizationId: string,
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse>;
|
||||
|
||||
abstract getOrganizationBillingMetadataVNext(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse>;
|
||||
|
||||
abstract getPlans(): Promise<ListResponse<PlanResponse>>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,35 +1,12 @@
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
|
||||
export class OrganizationBillingMetadataResponse extends BaseResponse {
|
||||
isEligibleForSelfHost: boolean;
|
||||
isManaged: boolean;
|
||||
isOnSecretsManagerStandalone: boolean;
|
||||
isSubscriptionUnpaid: boolean;
|
||||
hasSubscription: boolean;
|
||||
hasOpenInvoice: boolean;
|
||||
invoiceDueDate: Date | null;
|
||||
invoiceCreatedDate: Date | null;
|
||||
subPeriodEndDate: Date | null;
|
||||
isSubscriptionCanceled: boolean;
|
||||
organizationOccupiedSeats: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost");
|
||||
this.isManaged = this.getResponseProperty("IsManaged");
|
||||
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");
|
||||
}
|
||||
|
||||
private parseDate(dateString: any): Date | null {
|
||||
return dateString ? new Date(dateString) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ApiService } from "../../abstractions/api.service";
|
||||
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response";
|
||||
import { ListResponse } from "../../models/response/list.response";
|
||||
import { OrganizationId } from "../../types/guid";
|
||||
import { BillingApiServiceAbstraction } from "../abstractions";
|
||||
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
|
||||
import { SubscriptionCancellationRequest } from "../models/request/subscription-cancellation.request";
|
||||
@@ -48,7 +49,7 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
}
|
||||
|
||||
async getOrganizationBillingMetadata(
|
||||
organizationId: string,
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
@@ -61,6 +62,20 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
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>> {
|
||||
const r = await this.apiService.send("GET", "/plans", null, false, true);
|
||||
return new ListResponse(r, PlanResponse);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export enum FeatureFlag {
|
||||
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
|
||||
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
|
||||
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",
|
||||
|
||||
/* Key Management */
|
||||
@@ -100,6 +101,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
|
||||
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
|
||||
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
|
||||
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
|
||||
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
|
||||
|
||||
/* Key Management */
|
||||
|
||||
Reference in New Issue
Block a user