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:
@@ -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 }),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>>;
|
||||||
|
|||||||
@@ -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";
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user