mirror of
https://github.com/bitwarden/browser
synced 2025-12-14 15:23:33 +00:00
Migrate OrganizationService to StateProvider (#7895)
This commit is contained in:
@@ -2,6 +2,7 @@ import { map, Observable } from "rxjs";
|
||||
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
|
||||
@@ -86,34 +87,67 @@ export function canAccessImport(i18nService: I18nService) {
|
||||
|
||||
/**
|
||||
* Returns `true` if a user is a member of an organization (rather than only being a ProviderUser)
|
||||
* @deprecated Use organizationService.memberOrganizations$ instead
|
||||
* @deprecated Use organizationService.organizations$ with a filter instead
|
||||
*/
|
||||
export function isMember(org: Organization): boolean {
|
||||
return org.isMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes an observable stream of organizations. This service is meant to
|
||||
* be used widely across Bitwarden as the primary way of fetching organizations.
|
||||
* Risky operations like updates are isolated to the
|
||||
* internal extension `InternalOrganizationServiceAbstraction`.
|
||||
*/
|
||||
export abstract class OrganizationService {
|
||||
/**
|
||||
* Publishes state for all organizations under the active user.
|
||||
* @returns An observable list of organizations
|
||||
*/
|
||||
organizations$: Observable<Organization[]>;
|
||||
|
||||
/**
|
||||
* Organizations that the user is a member of (excludes organizations that they only have access to via a provider)
|
||||
*/
|
||||
// @todo Clean these up. Continuing to expand them is not recommended.
|
||||
// @see https://bitwarden.atlassian.net/browse/AC-2252
|
||||
memberOrganizations$: Observable<Organization[]>;
|
||||
|
||||
get$: (id: string) => Observable<Organization | undefined>;
|
||||
get: (id: string) => Organization;
|
||||
getByIdentifier: (identifier: string) => Organization;
|
||||
getAll: (userId?: string) => Promise<Organization[]>;
|
||||
/**
|
||||
* @deprecated For the CLI only
|
||||
* @param id id of the organization
|
||||
* @deprecated This is currently only used in the CLI, and should not be
|
||||
* used in any new calls. Use get$ instead for the time being, and we'll be
|
||||
* removing this method soon. See Jira for details:
|
||||
* https://bitwarden.atlassian.net/browse/AC-2252.
|
||||
*/
|
||||
getFromState: (id: string) => Promise<Organization>;
|
||||
canManageSponsorships: () => Promise<boolean>;
|
||||
hasOrganizations: () => boolean;
|
||||
hasOrganizations: () => Promise<boolean>;
|
||||
get$: (id: string) => Observable<Organization | undefined>;
|
||||
get: (id: string) => Promise<Organization>;
|
||||
getAll: (userId?: string) => Promise<Organization[]>;
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Big scary buttons that **update** organization state. These should only be
|
||||
* called from within admin-console scoped code. Extends the base
|
||||
* `OrganizationService` for easy access to `get` calls.
|
||||
* @internal
|
||||
*/
|
||||
export abstract class InternalOrganizationServiceAbstraction extends OrganizationService {
|
||||
replace: (organizations: { [id: string]: OrganizationData }) => Promise<void>;
|
||||
upsert: (OrganizationData: OrganizationData | OrganizationData[]) => Promise<void>;
|
||||
/**
|
||||
* Replaces state for the provided organization, or creates it if not found.
|
||||
* @param organization The organization state being saved.
|
||||
* @param userId The userId to replace state for. Defaults to the active
|
||||
* user.
|
||||
*/
|
||||
upsert: (OrganizationData: OrganizationData) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Replaces state for the entire registered organization list for the active user.
|
||||
* You probably don't want this unless you're calling from a full sync
|
||||
* operation or a logout. See `upsert` for creating & updating a single
|
||||
* organization in the state.
|
||||
* @param organizations A complete list of all organization state for the active
|
||||
* user.
|
||||
* @param userId The userId to replace state for. Defaults to the active
|
||||
* user.
|
||||
*/
|
||||
replace: (organizations: { [id: string]: OrganizationData }, userId?: UserId) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -320,6 +320,10 @@ export class Organization {
|
||||
return !this.useTotp;
|
||||
}
|
||||
|
||||
get canManageSponsorships() {
|
||||
return this.familySponsorshipAvailable || this.familySponsorshipFriendlyName !== null;
|
||||
}
|
||||
|
||||
static fromJSON(json: Jsonify<Organization>) {
|
||||
if (json == null) {
|
||||
return null;
|
||||
|
||||
@@ -1,114 +1,142 @@
|
||||
import { MockProxy, mock, any, mockClear } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { FakeActiveUserState } from "../../../../spec/fake-state";
|
||||
import { StateService } from "../../../platform/abstractions/state.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { OrganizationId, UserId } from "../../../types/guid";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
|
||||
import { OrganizationService, ORGANIZATIONS } from "./organization.service";
|
||||
|
||||
describe("Organization Service", () => {
|
||||
describe("OrganizationService", () => {
|
||||
let organizationService: OrganizationService;
|
||||
|
||||
let stateService: MockProxy<StateService>;
|
||||
let activeAccount: BehaviorSubject<string>;
|
||||
let activeAccountUnlocked: BehaviorSubject<boolean>;
|
||||
const fakeUserId = Utils.newGuid() as UserId;
|
||||
let fakeAccountService: FakeAccountService;
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
let fakeActiveUserState: FakeActiveUserState<Record<string, OrganizationData>>;
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
let accountService: FakeAccountService;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let activeUserOrganizationsState: FakeActiveUserState<Record<string, OrganizationData>>;
|
||||
|
||||
const resetStateService = async (
|
||||
customizeStateService: (stateService: MockProxy<StateService>) => void,
|
||||
) => {
|
||||
mockClear(stateService);
|
||||
stateService = mock<StateService>();
|
||||
stateService.activeAccount$ = activeAccount;
|
||||
stateService.activeAccountUnlocked$ = activeAccountUnlocked;
|
||||
customizeStateService(stateService);
|
||||
organizationService = new OrganizationService(stateService, stateProvider);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
};
|
||||
|
||||
function prepareStateProvider(): void {
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
/**
|
||||
* It is easier to read arrays than records in code, but we store a record
|
||||
* in state. This helper methods lets us build organization arrays in tests
|
||||
* and easily map them to records before storing them in state.
|
||||
*/
|
||||
function arrayToRecord(input: OrganizationData[]): Record<OrganizationId, OrganizationData> {
|
||||
if (input == null) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.fromEntries(input?.map((i) => [i.id, i]));
|
||||
}
|
||||
|
||||
function seedTestData(): void {
|
||||
activeUserOrganizationsState = stateProvider.activeUser.getFake(ORGANIZATIONS);
|
||||
activeUserOrganizationsState.nextState({ "1": organizationData("1", "Test Org") });
|
||||
/**
|
||||
* There are a few assertions in this spec that check for array equality
|
||||
* but want to ignore a specific index that _should_ be different. This
|
||||
* function takes two arrays, and an index. It checks for equality of the
|
||||
* arrays, but splices out the specified index from both arrays first.
|
||||
*/
|
||||
function expectIsEqualExceptForIndex(x: any[], y: any[], indexToExclude: number) {
|
||||
// Clone the arrays to avoid modifying the reference values
|
||||
const a = [...x];
|
||||
const b = [...y];
|
||||
delete a[indexToExclude];
|
||||
delete b[indexToExclude];
|
||||
expect(a).toEqual(b);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
activeAccount = new BehaviorSubject("123");
|
||||
activeAccountUnlocked = new BehaviorSubject(true);
|
||||
/**
|
||||
* Builds a simple mock `OrganizationData[]` array that can be used in tests
|
||||
* to populate state.
|
||||
* @param count The number of organizations to populate the list with. The
|
||||
* function returns undefined if this is less than 1. The default value is 1.
|
||||
* @param suffix A string to append to data fields on each organization.
|
||||
* This defaults to the index of the organization in the list.
|
||||
* @returns an `OrganizationData[]` array that can be used to populate
|
||||
* stateProvider.
|
||||
*/
|
||||
function buildMockOrganizations(count = 1, suffix?: string): OrganizationData[] {
|
||||
if (count < 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
stateService = mock<StateService>();
|
||||
stateService.activeAccount$ = activeAccount;
|
||||
stateService.activeAccountUnlocked$ = activeAccountUnlocked;
|
||||
function buildMockOrganization(id: OrganizationId, name: string, identifier: string) {
|
||||
const data = new OrganizationData({} as any, {} as any);
|
||||
data.id = id;
|
||||
data.name = name;
|
||||
data.identifier = identifier;
|
||||
|
||||
stateService.getOrganizations.calledWith(any()).mockResolvedValue({
|
||||
"1": organizationData("1", "Test Org"),
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
prepareStateProvider();
|
||||
const mockOrganizations = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const s = suffix ? suffix + i.toString() : i.toString();
|
||||
mockOrganizations.push(
|
||||
buildMockOrganization(("org" + s) as OrganizationId, "org" + s, "orgIdentifier" + s),
|
||||
);
|
||||
}
|
||||
|
||||
organizationService = new OrganizationService(stateService, stateProvider);
|
||||
return mockOrganizations;
|
||||
}
|
||||
|
||||
seedTestData();
|
||||
});
|
||||
/**
|
||||
* `OrganizationService` deals with multiple accounts at times. This helper
|
||||
* function can be used to add a new non-active account to the test data.
|
||||
* This function is **not** needed to handle creation of the first account,
|
||||
* as that is handled by the `FakeAccountService` in `mockAccountServiceWith()`
|
||||
* @returns The `UserId` of the newly created state account and the mock data
|
||||
* created for them as an `Organization[]`.
|
||||
*/
|
||||
async function addNonActiveAccountToStateProvider(): Promise<[UserId, OrganizationData[]]> {
|
||||
const nonActiveUserId = Utils.newGuid() as UserId;
|
||||
|
||||
afterEach(() => {
|
||||
activeAccount.complete();
|
||||
activeAccountUnlocked.complete();
|
||||
const mockOrganizations = buildMockOrganizations(10);
|
||||
const fakeNonActiveUserState = fakeStateProvider.singleUser.getFake(
|
||||
nonActiveUserId,
|
||||
ORGANIZATIONS,
|
||||
);
|
||||
fakeNonActiveUserState.nextState(arrayToRecord(mockOrganizations));
|
||||
|
||||
return [nonActiveUserId, mockOrganizations];
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
fakeAccountService = mockAccountServiceWith(fakeUserId);
|
||||
fakeStateProvider = new FakeStateProvider(fakeAccountService);
|
||||
fakeActiveUserState = fakeStateProvider.activeUser.getFake(ORGANIZATIONS);
|
||||
organizationService = new OrganizationService(fakeStateProvider);
|
||||
});
|
||||
|
||||
it("getAll", async () => {
|
||||
const mockData: OrganizationData[] = buildMockOrganizations(1);
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const orgs = await organizationService.getAll();
|
||||
expect(orgs).toHaveLength(1);
|
||||
const org = orgs[0];
|
||||
expect(org).toEqual({
|
||||
id: "1",
|
||||
name: "Test Org",
|
||||
identifier: "test",
|
||||
});
|
||||
expect(org).toEqual(new Organization(mockData[0]));
|
||||
});
|
||||
|
||||
describe("canManageSponsorships", () => {
|
||||
it("can because one is available", async () => {
|
||||
await resetStateService((stateService) => {
|
||||
stateService.getOrganizations.mockResolvedValue({
|
||||
"1": { ...organizationData("1", "Org"), familySponsorshipAvailable: true },
|
||||
});
|
||||
});
|
||||
|
||||
const mockData: OrganizationData[] = buildMockOrganizations(1);
|
||||
mockData[0].familySponsorshipAvailable = true;
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await organizationService.canManageSponsorships();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("can because one is used", async () => {
|
||||
await resetStateService((stateService) => {
|
||||
stateService.getOrganizations.mockResolvedValue({
|
||||
"1": { ...organizationData("1", "Test Org"), familySponsorshipFriendlyName: "Something" },
|
||||
});
|
||||
});
|
||||
|
||||
const mockData: OrganizationData[] = buildMockOrganizations(1);
|
||||
mockData[0].familySponsorshipFriendlyName = "Something";
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await organizationService.canManageSponsorships();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("can not because one isn't available or taken", async () => {
|
||||
await resetStateService((stateService) => {
|
||||
stateService.getOrganizations.mockResolvedValue({
|
||||
"1": { ...organizationData("1", "Org"), familySponsorshipFriendlyName: null },
|
||||
});
|
||||
});
|
||||
|
||||
const mockData: OrganizationData[] = buildMockOrganizations(1);
|
||||
mockData[0].familySponsorshipFriendlyName = null;
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await organizationService.canManageSponsorships();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
@@ -116,81 +144,181 @@ describe("Organization Service", () => {
|
||||
|
||||
describe("get", () => {
|
||||
it("exists", async () => {
|
||||
const result = organizationService.get("1");
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "1",
|
||||
name: "Test Org",
|
||||
identifier: "test",
|
||||
});
|
||||
const mockData = buildMockOrganizations(1);
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await organizationService.get(mockData[0].id);
|
||||
expect(result).toEqual(new Organization(mockData[0]));
|
||||
});
|
||||
|
||||
it("does not exist", async () => {
|
||||
const result = organizationService.get("2");
|
||||
|
||||
const result = await organizationService.get("this-org-does-not-exist");
|
||||
expect(result).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("upsert", async () => {
|
||||
await organizationService.upsert(organizationData("2", "Test 2"));
|
||||
describe("organizations$", () => {
|
||||
describe("null checking behavior", () => {
|
||||
it("publishes an empty array if organizations in state = undefined", async () => {
|
||||
const mockData: OrganizationData[] = undefined;
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await firstValueFrom(organizationService.organizations$);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
expect(await firstValueFrom(organizationService.organizations$)).toEqual([
|
||||
{
|
||||
id: "1",
|
||||
name: "Test Org",
|
||||
identifier: "test",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Test 2",
|
||||
identifier: "test",
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("publishes an empty array if organizations in state = null", async () => {
|
||||
const mockData: OrganizationData[] = null;
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await firstValueFrom(organizationService.organizations$);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
describe("getByIdentifier", () => {
|
||||
it("exists", async () => {
|
||||
const result = organizationService.getByIdentifier("test");
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "1",
|
||||
name: "Test Org",
|
||||
identifier: "test",
|
||||
it("publishes an empty array if organizations in state = []", async () => {
|
||||
const mockData: OrganizationData[] = [];
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await firstValueFrom(organizationService.organizations$);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not exist", async () => {
|
||||
const result = organizationService.getByIdentifier("blah");
|
||||
describe("parameter handling & returns", () => {
|
||||
it("publishes all organizations for the active user by default", async () => {
|
||||
const mockData = buildMockOrganizations(10);
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await firstValueFrom(organizationService.organizations$);
|
||||
expect(result).toEqual(mockData);
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
it("can be used to publish the organizations of a non active user if requested", async () => {
|
||||
const activeUserMockData = buildMockOrganizations(10, "activeUserState");
|
||||
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
|
||||
|
||||
const [nonActiveUserId, nonActiveUserMockOrganizations] =
|
||||
await addNonActiveAccountToStateProvider();
|
||||
// This can be updated to use
|
||||
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
|
||||
// promise based methods are removed from `OrganizationService` and the
|
||||
// main observable is refactored to accept a userId
|
||||
const result = await organizationService.getAll(nonActiveUserId);
|
||||
|
||||
expect(result).toEqual(nonActiveUserMockOrganizations);
|
||||
expect(result).not.toEqual(await firstValueFrom(organizationService.organizations$));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("exists", async () => {
|
||||
await organizationService.delete("1");
|
||||
|
||||
expect(stateService.getOrganizations).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(stateService.setOrganizations).toHaveBeenCalledTimes(1);
|
||||
describe("upsert()", () => {
|
||||
it("can create the organization list if necassary", async () => {
|
||||
// Notice that no default state is provided in this test, so the list in
|
||||
// `stateProvider` will be null when the `upsert` method is called.
|
||||
const mockData = buildMockOrganizations();
|
||||
await organizationService.upsert(mockData[0]);
|
||||
const result = await firstValueFrom(organizationService.organizations$);
|
||||
expect(result).toEqual(mockData.map((x) => new Organization(x)));
|
||||
});
|
||||
|
||||
it("does not exist", async () => {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
organizationService.delete("1");
|
||||
it("updates an organization that already exists in state, defaulting to the active user", async () => {
|
||||
const mockData = buildMockOrganizations(10);
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const indexToUpdate = 5;
|
||||
const anUpdatedOrganization = {
|
||||
...buildMockOrganizations(1, "UPDATED").pop(),
|
||||
id: mockData[indexToUpdate].id,
|
||||
};
|
||||
await organizationService.upsert(anUpdatedOrganization);
|
||||
const result = await firstValueFrom(organizationService.organizations$);
|
||||
expect(result[indexToUpdate]).not.toEqual(new Organization(mockData[indexToUpdate]));
|
||||
expect(result[indexToUpdate].id).toEqual(new Organization(mockData[indexToUpdate]).id);
|
||||
expectIsEqualExceptForIndex(
|
||||
result,
|
||||
mockData.map((x) => new Organization(x)),
|
||||
indexToUpdate,
|
||||
);
|
||||
});
|
||||
|
||||
expect(stateService.getOrganizations).toHaveBeenCalledTimes(2);
|
||||
it("can also update an organization in state for a non-active user, if requested", async () => {
|
||||
const activeUserMockData = buildMockOrganizations(10, "activeUserOrganizations");
|
||||
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
|
||||
|
||||
const [nonActiveUserId, nonActiveUserMockOrganizations] =
|
||||
await addNonActiveAccountToStateProvider();
|
||||
const indexToUpdate = 5;
|
||||
const anUpdatedOrganization = {
|
||||
...buildMockOrganizations(1, "UPDATED").pop(),
|
||||
id: nonActiveUserMockOrganizations[indexToUpdate].id,
|
||||
};
|
||||
|
||||
await organizationService.upsert(anUpdatedOrganization, nonActiveUserId);
|
||||
// This can be updated to use
|
||||
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
|
||||
// promise based methods are removed from `OrganizationService` and the
|
||||
// main observable is refactored to accept a userId
|
||||
const result = await organizationService.getAll(nonActiveUserId);
|
||||
|
||||
expect(result[indexToUpdate]).not.toEqual(
|
||||
new Organization(nonActiveUserMockOrganizations[indexToUpdate]),
|
||||
);
|
||||
expect(result[indexToUpdate].id).toEqual(
|
||||
new Organization(nonActiveUserMockOrganizations[indexToUpdate]).id,
|
||||
);
|
||||
expectIsEqualExceptForIndex(
|
||||
result,
|
||||
nonActiveUserMockOrganizations.map((x) => new Organization(x)),
|
||||
indexToUpdate,
|
||||
);
|
||||
|
||||
// Just to be safe, lets make sure the active user didn't get updated
|
||||
// at all
|
||||
const activeUserState = await firstValueFrom(organizationService.organizations$);
|
||||
expect(activeUserState).toEqual(activeUserMockData.map((x) => new Organization(x)));
|
||||
expect(activeUserState).not.toEqual(result);
|
||||
});
|
||||
});
|
||||
|
||||
function organizationData(id: string, name: string) {
|
||||
const data = new OrganizationData({} as any, {} as any);
|
||||
data.id = id;
|
||||
data.name = name;
|
||||
data.identifier = "test";
|
||||
describe("replace()", () => {
|
||||
it("replaces the entire organization list in state, defaulting to the active user", async () => {
|
||||
const originalData = buildMockOrganizations(10);
|
||||
fakeActiveUserState.nextState(arrayToRecord(originalData));
|
||||
|
||||
return data;
|
||||
}
|
||||
const newData = buildMockOrganizations(10, "newData");
|
||||
await organizationService.replace(arrayToRecord(newData));
|
||||
|
||||
const result = await firstValueFrom(organizationService.organizations$);
|
||||
|
||||
expect(result).toEqual(newData);
|
||||
expect(result).not.toEqual(originalData);
|
||||
});
|
||||
|
||||
// This is more or less a test for logouts
|
||||
it("can replace state with null", async () => {
|
||||
const originalData = buildMockOrganizations(2);
|
||||
fakeActiveUserState.nextState(arrayToRecord(originalData));
|
||||
await organizationService.replace(null);
|
||||
const result = await firstValueFrom(organizationService.organizations$);
|
||||
expect(result).toEqual([]);
|
||||
expect(result).not.toEqual(originalData);
|
||||
});
|
||||
|
||||
it("can also replace state for a non-active user, if requested", async () => {
|
||||
const activeUserMockData = buildMockOrganizations(10, "activeUserOrganizations");
|
||||
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
|
||||
|
||||
const [nonActiveUserId, originalOrganizations] = await addNonActiveAccountToStateProvider();
|
||||
const newData = buildMockOrganizations(10, "newData");
|
||||
|
||||
await organizationService.replace(arrayToRecord(newData), nonActiveUserId);
|
||||
// This can be updated to use
|
||||
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
|
||||
// promise based methods are removed from `OrganizationService` and the
|
||||
// main observable is refactored to accept a userId
|
||||
const result = await organizationService.getAll(nonActiveUserId);
|
||||
expect(result).toEqual(newData);
|
||||
expect(result).not.toEqual(originalOrganizations);
|
||||
|
||||
// Just to be safe, lets make sure the active user didn't get updated
|
||||
// at all
|
||||
const activeUserState = await firstValueFrom(organizationService.organizations$);
|
||||
expect(activeUserState).toEqual(activeUserMockData.map((x) => new Organization(x)));
|
||||
expect(activeUserState).not.toEqual(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,111 +1,105 @@
|
||||
import { BehaviorSubject, concatMap, map, Observable } from "rxjs";
|
||||
import { map, Observable, firstValueFrom } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { StateService } from "../../../platform/abstractions/state.service";
|
||||
import { KeyDefinition, ORGANIZATIONS_DISK, StateProvider } from "../../../platform/state";
|
||||
import {
|
||||
InternalOrganizationServiceAbstraction,
|
||||
isMember,
|
||||
} from "../../abstractions/organization/organization.service.abstraction";
|
||||
import { ORGANIZATIONS_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { InternalOrganizationServiceAbstraction } from "../../abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
|
||||
export const ORGANIZATIONS = KeyDefinition.record<OrganizationData>(
|
||||
/**
|
||||
* The `KeyDefinition` for accessing organization lists in application state.
|
||||
* @todo Ideally this wouldn't require a `fromJSON()` call, but `OrganizationData`
|
||||
* has some properties that contain functions. This should probably get
|
||||
* cleaned up.
|
||||
*/
|
||||
export const ORGANIZATIONS = UserKeyDefinition.record<OrganizationData>(
|
||||
ORGANIZATIONS_DISK,
|
||||
"organizations",
|
||||
{
|
||||
deserializer: (obj: Jsonify<OrganizationData>) => OrganizationData.fromJSON(obj),
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Filter out organizations from an observable that __do not__ offer a
|
||||
* families-for-enterprise sponsorship to members.
|
||||
* @returns a function that can be used in `Observable<Organization[]>` pipes,
|
||||
* like `organizationService.organizations$`
|
||||
*/
|
||||
function mapToExcludeOrganizationsWithoutFamilySponsorshipSupport() {
|
||||
return map<Organization[], Organization[]>((orgs) => orgs.filter((o) => o.canManageSponsorships));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out organizations from an observable that the organization user
|
||||
* __is not__ a direct member of. This will exclude organizations only
|
||||
* accessible as a provider.
|
||||
* @returns a function that can be used in `Observable<Organization[]>` pipes,
|
||||
* like `organizationService.organizations$`
|
||||
*/
|
||||
function mapToExcludeProviderOrganizations() {
|
||||
return map<Organization[], Organization[]>((orgs) => orgs.filter((o) => o.isMember));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an observable stream of organizations down to a boolean indicating
|
||||
* if any organizations exist (`orgs.length > 0`).
|
||||
* @returns a function that can be used in `Observable<Organization[]>` pipes,
|
||||
* like `organizationService.organizations$`
|
||||
*/
|
||||
function mapToBooleanHasAnyOrganizations() {
|
||||
return map<Organization[], boolean>((orgs) => orgs.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an observable stream of organizations down to a single organization.
|
||||
* @param `organizationId` The ID of the organization you'd like to subscribe to
|
||||
* @returns a function that can be used in `Observable<Organization[]>` pipes,
|
||||
* like `organizationService.organizations$`
|
||||
*/
|
||||
function mapToSingleOrganization(organizationId: string) {
|
||||
return map<Organization[], Organization>((orgs) => orgs?.find((o) => o.id === organizationId));
|
||||
}
|
||||
|
||||
export class OrganizationService implements InternalOrganizationServiceAbstraction {
|
||||
// marked for removal during AC-2009
|
||||
protected _organizations = new BehaviorSubject<Organization[]>([]);
|
||||
// marked for removal during AC-2009
|
||||
organizations$ = this._organizations.asObservable();
|
||||
// marked for removal during AC-2009
|
||||
memberOrganizations$ = this.organizations$.pipe(map((orgs) => orgs.filter(isMember)));
|
||||
organizations$ = this.getOrganizationsFromState$();
|
||||
memberOrganizations$ = this.organizations$.pipe(mapToExcludeProviderOrganizations());
|
||||
|
||||
activeUserOrganizations$: Observable<Organization[]>;
|
||||
activeUserMemberOrganizations$: Observable<Organization[]>;
|
||||
|
||||
constructor(
|
||||
private stateService: StateService,
|
||||
private stateProvider: StateProvider,
|
||||
) {
|
||||
this.activeUserOrganizations$ = this.stateProvider
|
||||
.getActive(ORGANIZATIONS)
|
||||
.state$.pipe(map((data) => Object.values(data).map((o) => new Organization(o))));
|
||||
|
||||
this.activeUserMemberOrganizations$ = this.activeUserOrganizations$.pipe(
|
||||
map((orgs) => orgs.filter(isMember)),
|
||||
);
|
||||
|
||||
this.stateService.activeAccountUnlocked$
|
||||
.pipe(
|
||||
concatMap(async (unlocked) => {
|
||||
if (!unlocked) {
|
||||
this._organizations.next([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await this.stateService.getOrganizations();
|
||||
this.updateObservables(data);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
constructor(private stateProvider: StateProvider) {}
|
||||
|
||||
get$(id: string): Observable<Organization | undefined> {
|
||||
return this.organizations$.pipe(map((orgs) => orgs.find((o) => o.id === id)));
|
||||
return this.organizations$.pipe(mapToSingleOrganization(id));
|
||||
}
|
||||
|
||||
async getAll(userId?: string): Promise<Organization[]> {
|
||||
const organizationsMap = await this.stateService.getOrganizations({ userId: userId });
|
||||
return Object.values(organizationsMap || {}).map((o) => new Organization(o));
|
||||
return await firstValueFrom(this.getOrganizationsFromState$(userId as UserId));
|
||||
}
|
||||
|
||||
async canManageSponsorships(): Promise<boolean> {
|
||||
const organizations = this._organizations.getValue();
|
||||
return organizations.some(
|
||||
(o) => o.familySponsorshipAvailable || o.familySponsorshipFriendlyName !== null,
|
||||
return await firstValueFrom(
|
||||
this.organizations$.pipe(
|
||||
mapToExcludeOrganizationsWithoutFamilySponsorshipSupport(),
|
||||
mapToBooleanHasAnyOrganizations(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
hasOrganizations(): boolean {
|
||||
const organizations = this._organizations.getValue();
|
||||
return organizations.length > 0;
|
||||
async hasOrganizations(): Promise<boolean> {
|
||||
return await firstValueFrom(this.organizations$.pipe(mapToBooleanHasAnyOrganizations()));
|
||||
}
|
||||
|
||||
async upsert(organization: OrganizationData): Promise<void> {
|
||||
let organizations = await this.stateService.getOrganizations();
|
||||
if (organizations == null) {
|
||||
organizations = {};
|
||||
}
|
||||
|
||||
organizations[organization.id] = organization;
|
||||
|
||||
await this.replace(organizations);
|
||||
async upsert(organization: OrganizationData, userId?: UserId): Promise<void> {
|
||||
await this.stateFor(userId).update((existingOrganizations) => {
|
||||
const organizations = existingOrganizations ?? {};
|
||||
organizations[organization.id] = organization;
|
||||
return organizations;
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const organizations = await this.stateService.getOrganizations();
|
||||
if (organizations == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (organizations[id] == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete organizations[id];
|
||||
await this.replace(organizations);
|
||||
}
|
||||
|
||||
get(id: string): Organization {
|
||||
const organizations = this._organizations.getValue();
|
||||
|
||||
return organizations.find((organization) => organization.id === id);
|
||||
async get(id: string): Promise<Organization> {
|
||||
return await firstValueFrom(this.organizations$.pipe(mapToSingleOrganization(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,28 +107,46 @@ export class OrganizationService implements InternalOrganizationServiceAbstracti
|
||||
* @param id id of the organization
|
||||
*/
|
||||
async getFromState(id: string): Promise<Organization> {
|
||||
const organizationsMap = await this.stateService.getOrganizations();
|
||||
const organization = organizationsMap[id];
|
||||
if (organization == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Organization(organization);
|
||||
return await firstValueFrom(this.organizations$.pipe(mapToSingleOrganization(id)));
|
||||
}
|
||||
|
||||
getByIdentifier(identifier: string): Organization {
|
||||
const organizations = this._organizations.getValue();
|
||||
|
||||
return organizations.find((organization) => organization.identifier === identifier);
|
||||
async replace(organizations: { [id: string]: OrganizationData }, userId?: UserId): Promise<void> {
|
||||
await this.stateFor(userId).update(() => organizations);
|
||||
}
|
||||
|
||||
async replace(organizations: { [id: string]: OrganizationData }) {
|
||||
await this.stateService.setOrganizations(organizations);
|
||||
this.updateObservables(organizations);
|
||||
// Ideally this method would be renamed to organizations$() and the
|
||||
// $organizations observable as it stands would be removed. This will
|
||||
// require updates to callers, and so this method exists as a temporary
|
||||
// workaround until we have time & a plan to update callers.
|
||||
//
|
||||
// It can be thought of as "organizations$ but with a userId option".
|
||||
private getOrganizationsFromState$(userId?: UserId): Observable<Organization[] | undefined> {
|
||||
return this.stateFor(userId).state$.pipe(this.mapOrganizationRecordToArray());
|
||||
}
|
||||
|
||||
private updateObservables(organizationsMap: { [id: string]: OrganizationData }) {
|
||||
const organizations = Object.values(organizationsMap || {}).map((o) => new Organization(o));
|
||||
this._organizations.next(organizations);
|
||||
/**
|
||||
* Accepts a record of `OrganizationData`, which is how we store the
|
||||
* organization list as a JSON object on disk, to an array of
|
||||
* `Organization`, which is how the data is published to callers of the
|
||||
* service.
|
||||
* @returns a function that can be used to pipe organization data from
|
||||
* stored state to an exposed object easily consumable by others.
|
||||
*/
|
||||
private mapOrganizationRecordToArray() {
|
||||
return map<Record<string, OrganizationData>, Organization[]>((orgs) =>
|
||||
Object.values(orgs ?? {})?.map((o) => new Organization(o)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the organization list from on disk state for the specified user.
|
||||
* @param userId the user ID to fetch the organization list for. Defaults to
|
||||
* the currently active user.
|
||||
* @returns an observable of organization state as it is stored on disk.
|
||||
*/
|
||||
private stateFor(userId?: UserId) {
|
||||
return userId
|
||||
? this.stateProvider.getUser(userId, ORGANIZATIONS)
|
||||
: this.stateProvider.getActive(ORGANIZATIONS);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user