Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { ClerkAPIResponseError } from '@/error';

import { INTERNAL_STABLE_KEYS } from '../../stable-keys';
import { createCacheKeys } from '../createCacheKeys';
import { __internal_useOrganizationDirectorySync } from '../useOrganizationDirectorySync';
import { createMockClerk, createMockQueryClient } from './mocks/clerk';
import { wrapper } from './wrapper';

const directory = { id: 'dir_1', enterpriseConnectionId: 'ent_1' };
const getDirectorySyncSpy = vi.fn((_enterpriseConnectionId: string) => Promise.resolve(directory));

const defaultQueryClient = createMockQueryClient();

const mockClerk = createMockClerk({
queryClient: defaultQueryClient,
__internal_lastEmittedResources: {
user: null,
session: null,
organization: { id: 'org_1', getDirectorySync: getDirectorySyncSpy },
client: null,
},
});

vi.mock('../../contexts', () => ({
useAssertWrappedByClerkProvider: () => {},
useClerkInstanceContext: () => mockClerk,
useInitialStateContext: () => undefined,
}));

const keysFor = (enterpriseConnectionId: string) =>
createCacheKeys({
stablePrefix: INTERNAL_STABLE_KEYS.ORGANIZATION_DIRECTORY_SYNC_KEY,
authenticated: true,
tracked: { organizationId: 'org_1', enterpriseConnectionId },
untracked: { args: {} },
});

const renderDirectorySync = (enterpriseConnectionId: string | null = 'ent_1') =>
renderHook(() => __internal_useOrganizationDirectorySync({ enterpriseConnectionId }), { wrapper });

describe('useOrganizationDirectorySync', () => {
beforeEach(() => {
vi.clearAllMocks();
defaultQueryClient.client.clear();
mockClerk.loaded = true;
});

it('resolves the directory for the connection', async () => {
const { result } = renderDirectorySync();
await waitFor(() => expect(result.current.isLoading).toBe(false));

expect(getDirectorySyncSpy).toHaveBeenCalledWith('ent_1');
expect(result.current.data).toBe(directory);
expect(result.current.error).toBeNull();
});

it('treats a 404 as "no directory yet" and resolves null instead of an error', async () => {
getDirectorySyncSpy.mockRejectedValueOnce(new ClerkAPIResponseError('Not found', { status: 404, data: [] }));

const { result } = renderDirectorySync();
await waitFor(() => expect(result.current.isLoading).toBe(false));

expect(result.current.data).toBeNull();
expect(result.current.error).toBeNull();
});

it('stays dormant without an enterprise connection id', () => {
const { result } = renderDirectorySync(null);

expect(getDirectorySyncSpy).not.toHaveBeenCalled();
expect(result.current.data).toBeUndefined();
});

it('revalidate refetches only this org+connection, leaving other connections cached', async () => {
const { queryKey: otherKey } = keysFor('ent_other');
defaultQueryClient.client.setQueryData(otherKey, { id: 'dir_other', enterpriseConnectionId: 'ent_other' });

const { result } = renderDirectorySync();
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(getDirectorySyncSpy).toHaveBeenCalledTimes(1);

await act(async () => {
await result.current.revalidate();
});

expect(getDirectorySyncSpy).toHaveBeenCalledTimes(2);
expect(defaultQueryClient.client.getQueryState(otherKey)?.isInvalidated).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { act, render, renderHook, waitFor } from '@testing-library/react';
import React, { useEffect } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { DirectorySyncResource } from '@/types/directorySync';

import type { UseOrganizationDirectorySyncUsersReturn } from '../useOrganizationDirectorySyncUsers';
import { __internal_useOrganizationDirectorySyncUsers } from '../useOrganizationDirectorySyncUsers';
import { createMockClerk, createMockQueryClient } from './mocks/clerk';
import { wrapper } from './wrapper';

const POLL_INTERVAL_MS = 20;

const getUsersSpy = vi.fn(() => Promise.resolve({ data: [{ id: 'du_1' }], total_count: 1 }));

const createDirectory = (id: string) =>
({ id, enterpriseConnectionId: 'ent_1', getUsers: getUsersSpy }) as unknown as DirectorySyncResource;

const defaultQueryClient = createMockQueryClient();

const mockClerk = createMockClerk({
queryClient: defaultQueryClient,
__internal_lastEmittedResources: {
user: null,
session: null,
organization: { id: 'org_1' },
client: null,
},
});

vi.mock('../../contexts', () => ({
useAssertWrappedByClerkProvider: () => {},
useClerkInstanceContext: () => mockClerk,
useInitialStateContext: () => undefined,
}));

const renderUsers = (initialDirectory: DirectorySyncResource | null) =>
renderHook(
({ directory }: { directory: DirectorySyncResource | null }) =>
__internal_useOrganizationDirectorySyncUsers({ directory, pollIntervalMs: POLL_INTERVAL_MS }),
{ wrapper, initialProps: { directory: initialDirectory } },
);

describe('useOrganizationDirectorySyncUsers', () => {
beforeEach(() => {
vi.clearAllMocks();
defaultQueryClient.client.clear();
mockClerk.loaded = true;
});

it('stays dormant without a directory', () => {
const { result } = renderUsers(null);

expect(getUsersSpy).not.toHaveBeenCalled();
expect(result.current.data).toBeUndefined();
expect(result.current.isPolling).toBe(false);
});

it('polls while armed and stops on stopPolling', async () => {
const { result } = renderUsers(createDirectory('dir_1'));
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.isPolling).toBe(false);

act(() => result.current.startPolling());
expect(result.current.isPolling).toBe(true);
await waitFor(() => expect(getUsersSpy.mock.calls.length).toBeGreaterThanOrEqual(3));

act(() => result.current.stopPolling());
expect(result.current.isPolling).toBe(false);
});

it('disarms polling when the directory identity changes', async () => {
const { result, rerender } = renderUsers(createDirectory('dir_1'));
await waitFor(() => expect(result.current.isLoading).toBe(false));

act(() => result.current.startPolling());
expect(result.current.isPolling).toBe(true);

rerender({ directory: createDirectory('dir_2') });
expect(result.current.isPolling).toBe(false);
});

it('keeps polling armed by a child effect in the same commit the directory arrives', async () => {
let latest: UseOrganizationDirectorySyncUsersReturn | undefined;

// Child effects run before parent effects, so this is the ordering a
// reset-in-effect implementation would silently cancel.
const Child = ({
directory,
startPolling,
}: {
directory: DirectorySyncResource | null;
startPolling: () => void;
}) => {
useEffect(() => {
if (directory) {
startPolling();
}
}, [directory, startPolling]);
return null;
};

const Parent = ({ directory }: { directory: DirectorySyncResource | null }) => {
latest = __internal_useOrganizationDirectorySyncUsers({ directory, pollIntervalMs: POLL_INTERVAL_MS });
return (
<Child
directory={directory}
startPolling={latest.startPolling}
/>
);
};

const { rerender } = render(<Parent directory={null} />);
expect(latest?.isPolling).toBe(false);

rerender(<Parent directory={createDirectory('dir_1')} />);

await waitFor(() => expect(latest?.isPolling).toBe(true));
await waitFor(() => expect(getUsersSpy.mock.calls.length).toBeGreaterThanOrEqual(3));
});
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { act, render, renderHook, waitFor } from '@testing-library/react';
import React, { useEffect } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { GetEnterpriseConnectionTestRunsParams } from '@/types/enterpriseConnectionTestRun';

import { INTERNAL_STABLE_KEYS } from '../../stable-keys';
import { createCacheKeys } from '../createCacheKeys';
import type { UseOrganizationEnterpriseConnectionTestRunsReturn } from '../useOrganizationEnterpriseConnectionTestRuns';
import { __internal_useOrganizationEnterpriseConnectionTestRuns } from '../useOrganizationEnterpriseConnectionTestRuns';
import { createMockClerk, createMockQueryClient } from './mocks/clerk';
import { wrapper } from './wrapper';
Expand Down Expand Up @@ -99,3 +101,69 @@ describe('useOrganizationEnterpriseConnectionTestRuns — revalidate invalidatio
invalidateSpy.mockRestore();
});
});

describe('useOrganizationEnterpriseConnectionTestRuns — polling arm scope', () => {
beforeEach(() => {
vi.clearAllMocks();
defaultQueryClient.client.clear();
mockClerk.loaded = true;
});

it('keeps polling armed by a child effect in the same commit the connection arrives', async () => {
getTestRunsSpy.mockImplementation(() => Promise.resolve({ data: [], total_count: 0 }));
let latest: UseOrganizationEnterpriseConnectionTestRunsReturn | undefined;

// Child effects run before parent effects, so this is the ordering a
// reset-in-effect implementation would silently cancel.
const Child = ({
enterpriseConnectionId,
revalidate,
}: {
enterpriseConnectionId: string | null;
revalidate: UseOrganizationEnterpriseConnectionTestRunsReturn['revalidate'];
}) => {
useEffect(() => {
if (enterpriseConnectionId) {
void revalidate();
}
}, [enterpriseConnectionId, revalidate]);
return null;
};

const Parent = ({ enterpriseConnectionId }: { enterpriseConnectionId: string | null }) => {
latest = __internal_useOrganizationEnterpriseConnectionTestRuns({ enterpriseConnectionId, pollIntervalMs: 20 });
return (
<Child
enterpriseConnectionId={enterpriseConnectionId}
revalidate={latest.revalidate}
/>
);
};

const { rerender } = render(<Parent enterpriseConnectionId={null} />);
expect(latest?.isPolling).toBe(false);

rerender(<Parent enterpriseConnectionId='ent_1' />);

await waitFor(() => expect(latest?.isPolling).toBe(true));
await waitFor(() => expect(getTestRunsSpy.mock.calls.length).toBeGreaterThanOrEqual(3));
});

it('disarms polling when the connection changes', async () => {
getTestRunsSpy.mockImplementation(() => Promise.resolve({ data: [], total_count: 0 }));
const { result, rerender } = renderHook(
({ enterpriseConnectionId }: { enterpriseConnectionId: string }) =>
__internal_useOrganizationEnterpriseConnectionTestRuns({ enterpriseConnectionId, pollIntervalMs: 20 }),
{ wrapper, initialProps: { enterpriseConnectionId: 'ent_1' } },
);
await waitFor(() => expect(result.current.isLoading).toBe(false));

await act(async () => {
await result.current.revalidate();
});
expect(result.current.isPolling).toBe(true);

rerender({ enterpriseConnectionId: 'ent_2' });
expect(result.current.isPolling).toBe(false);
});
});
10 changes: 10 additions & 0 deletions packages/shared/src/react/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ export type {
} from './useOrganizationEnterpriseConnections';
export { __internal_useOrganizationDomains } from './useOrganizationDomains';
export type { UseOrganizationDomainsParams, UseOrganizationDomainsReturn } from './useOrganizationDomains';
export { __internal_useOrganizationDirectorySync } from './useOrganizationDirectorySync';
export type {
UseOrganizationDirectorySyncParams,
UseOrganizationDirectorySyncReturn,
} from './useOrganizationDirectorySync';
export { __internal_useOrganizationDirectorySyncUsers } from './useOrganizationDirectorySyncUsers';
export type {
UseOrganizationDirectorySyncUsersParams,
UseOrganizationDirectorySyncUsersReturn,
} from './useOrganizationDirectorySyncUsers';
export { __internal_useOrganizationEnterpriseConnectionTestRuns } from './useOrganizationEnterpriseConnectionTestRuns';
export type {
UseOrganizationEnterpriseConnectionTestRunsParams,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useMemo } from 'react';

import type { GetDirectorySyncUsersParams } from '../../types/directorySync';
import { INTERNAL_STABLE_KEYS } from '../stable-keys';
import { createCacheKeys } from './createCacheKeys';

/**
* @internal
*/
export function useOrganizationDirectorySyncCacheKeys(params: {
organizationId: string | null;
enterpriseConnectionId: string | null;
}) {
const { organizationId, enterpriseConnectionId } = params;
return useMemo(() => {
return createCacheKeys({
stablePrefix: INTERNAL_STABLE_KEYS.ORGANIZATION_DIRECTORY_SYNC_KEY,
authenticated: Boolean(organizationId),
tracked: {
organizationId: organizationId ?? null,
enterpriseConnectionId: enterpriseConnectionId ?? null,
},
untracked: {
args: {},
},
});
}, [organizationId, enterpriseConnectionId]);
}

/**
* @internal
*/
export function useOrganizationDirectorySyncUsersCacheKeys(params: {
organizationId: string | null;
enterpriseConnectionId: string | null;
directoryId: string | null;
args: GetDirectorySyncUsersParams;
}) {
const { organizationId, enterpriseConnectionId, directoryId, args } = params;
return useMemo(() => {
return createCacheKeys({
stablePrefix: INTERNAL_STABLE_KEYS.ORGANIZATION_DIRECTORY_SYNC_USERS_KEY,
authenticated: Boolean(organizationId),
tracked: {
organizationId: organizationId ?? null,
enterpriseConnectionId: enterpriseConnectionId ?? null,
directoryId: directoryId ?? null,
},
untracked: {
args,
},
});
// The args object is intentionally serialized via the consumer to keep stability.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organizationId, enterpriseConnectionId, directoryId, JSON.stringify(args)]);
}
Loading
Loading