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:
@@ -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