mattermost-mobile-upstream/app/hooks/access_control_attributes.test.ts
Elias Nahum b6fc85385e
Migrate from RNN to Expo router (#9402)
* Expo-Router partial migration

* Full migration to ExpoRouter

* i18n

* add tests

* Merge branch 'main' into expo-router

* align package-lock.json

* i18n

* feedback review

* feedback review

* android 16kb patch

* Refactored emoji picker keyboard using a state machine

* set android launch_screen in drawables instead of layout

* no conditional hooks

* remove duplicate call in test

* address feedback

* fix tests and address more feedback

* small corrections

* small corrections

* small corrections

* fix draft test

* fix draft test 2

* remove navigateToScreen in draff test

* fix nit in draft test

* remove doc file

* Feedback review

* fix snapshots

* improve test coverage

* fix i18n

* Upgrade t o RN 0.83, other dependencies and new architecture (#9737)

* library hardware-keyboard android new arch support fix

* library rnshare android new arch support fix

* library rnutils android new arch support fix

* library secure-pdf-viewer add new arch support

* Enable new arch on iOS and Android

* update emm and network client with new-arch

* update intune to use new-arch

* performance improvements

* fix tests

* fixes post input on new-arch

* fix android emoji picker

* remove doc

* run tests in parallel

* upgrade to React Native 0.83.9, Expo 55, React 19, and New Architecture

Bumps core dependencies to their latest versions:
- React Native 0.77.3 → 0.83.9 (New Architecture enabled)
- React 18.3.1 → 19.2.5
- Expo SDK 52 → 55
- TypeScript 5.8.3 → 6.0.3
- Node.js 22.16.0 → 24.15.0
- react-native-reanimated 3.17.3 → 4.3.0
- Various other dependency upgrades

Migrates iOS AppDelegate from Objective-C to Swift, updates Android
build tooling and Gradle wrapper, refreshes all patches and font
assets, and adapts app code, tests, and CI workflows throughout for
API changes introduced by these upgrades.

* remove unecessary dismissKeyboard in notifications

* feedback review

* update intune to use new-arch

* update @mattermost deps

* missing i18n string

* additional fixes

* update fastlane
2026-05-13 17:44:55 +08:00

329 lines
14 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {renderHook, act, waitFor} from '@testing-library/react-native';
import * as ChannelAccessControlAttributesActions from '@actions/remote/channel_access_control_attributes';
import {useServerUrl} from '@context/server';
// Access the module-level cache to reset it between tests
// This is necessary because the cache persists between test runs
// @ts-ignore - Accessing private module variable for testing
import * as AccessControlAttributesModule from './access_control_attributes';
import {useAccessControlAttributes} from './access_control_attributes';
const MOCK_SERVER_URL = 'https://test-server.mattermost.com';
jest.mock('@context/server', () => ({
useServerUrl: jest.fn(() => MOCK_SERVER_URL),
}));
jest.mock('@actions/remote/channel_access_control_attributes', () => ({
fetchChannelAccessControlAttributes: jest.fn(),
}));
describe('useAccessControlAttributes', () => {
const mockServerUrl = MOCK_SERVER_URL;
const mockEntityId = 'channel-id';
const mockAttributes = {
groups: ['group1', 'group2'],
locations: ['location1'],
departments: ['department1', 'department2', 'department3'],
};
beforeEach(() => {
jest.resetAllMocks();
(useServerUrl as jest.Mock).mockReturnValue(mockServerUrl);
// Clear the module-level cache before each test
// @ts-ignore - Accessing private module variable for testing
if (AccessControlAttributesModule.attributesCache) {
// @ts-ignore - Accessing private module variable for testing
Object.keys(AccessControlAttributesModule.attributesCache).forEach((key) => {
// @ts-ignore - Accessing private module variable for testing
delete AccessControlAttributesModule.attributesCache[key];
});
}
});
afterEach(() => {
jest.resetAllMocks();
});
it('should return empty tags when abac policy is not enforced', async () => {
const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, false));
expect(result.current.attributeTags).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).not.toHaveBeenCalled();
});
it('should return empty tags when entityId is undefined', async () => {
const {result} = renderHook(() => useAccessControlAttributes('channel', undefined, true));
expect(result.current.attributeTags).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).not.toHaveBeenCalled();
});
it('should fetch and process attributes when abac policy is enforced', async () => {
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue({
attributes: mockAttributes,
});
const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true));
// Initial state might be false if the hook sets loading after the first render
// expect(result.current.loading).toBe(true);
expect(result.current.attributeTags).toEqual([]);
// Wait for the fetch to complete
await waitFor(() => expect(result.current.loading).toBe(false));
// Check that the fetch was called with the correct parameters
expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledWith(
mockServerUrl,
mockEntityId,
);
// Check that the attributes were processed correctly
expect(result.current.attributeTags).toEqual(['group1', 'group2', 'location1', 'department1', 'department2', 'department3']);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should handle errors when fetching attributes', async () => {
const mockError = new Error('Failed to fetch attributes');
// Reset all mocks to ensure a clean state
jest.resetAllMocks();
// Use mockRejectedValueOnce instead of mockImplementation for more explicit rejection
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockRejectedValueOnce(mockError);
// Render the hook
const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true));
// Manually trigger a fetch with forceRefresh to bypass cache
await act(async () => {
await result.current.fetchAttributes(true);
});
// Check that the error was handled correctly
expect(result.current.attributeTags).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(mockError);
});
it('should handle null or undefined values in attributes', async () => {
// Clear any previous mock implementations
jest.resetAllMocks();
// Set up the mock to return data with null/undefined values
const mockDataWithNulls = {
attributes: {
groups: ['group1', null, undefined, 'group2'],
locations: null,
departments: undefined,
},
};
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue(mockDataWithNulls);
// Render the hook with act to handle all state updates
const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true));
// Wait for the initial fetch to complete
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
// Check that null and undefined values were filtered out
expect(result.current.attributeTags).toEqual(['group1', 'group2']);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should handle non-object attributes', async () => {
// Clear any previous mock implementations
jest.resetAllMocks();
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValueOnce({
attributes: 'not an object',
});
// Render the hook
const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true));
// Manually trigger a fetch with forceRefresh to bypass cache
await act(async () => {
await result.current.fetchAttributes(true);
});
// Check that non-object attributes result in empty tags
expect(result.current.attributeTags).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should handle non-array values in attributes', async () => {
// Clear any previous mock implementations
jest.resetAllMocks();
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValueOnce({
attributes: {
groups: 'not an array',
locations: 123,
departments: {key: 'value'},
},
});
// Render the hook
const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true));
// Manually trigger a fetch with forceRefresh to bypass cache
await act(async () => {
await result.current.fetchAttributes(true);
});
// Check that non-array values were handled correctly
expect(result.current.attributeTags).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
});
// This test verifies that the hook correctly handles dependency changes
it('should refetch when dependencies change', async () => {
// Mock the API call to return different data for different entity IDs
const firstEntityData = {
attributes: {
groups: ['group1', 'group2'],
},
};
const secondEntityData = {
attributes: {
departments: ['department1', 'department2'],
},
};
// Set up the mock to return the first data set
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue(firstEntityData);
// Render the hook with the first entity ID and manually trigger a fetch
const {result: result1} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true));
// Manually trigger a fetch to avoid waiting for the automatic fetch
await act(async () => {
await result1.current.fetchAttributes(true); // Use forceRefresh to bypass cache
});
// Verify the API was called with the correct parameters
expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledWith(
mockServerUrl,
mockEntityId,
);
// Verify the correct data was processed
expect(result1.current.attributeTags).toEqual(['group1', 'group2']);
// Reset mocks for the second test
jest.clearAllMocks();
// Set up the mock to return the second data set
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue(secondEntityData);
// Render the hook with a different entity ID
const newEntityId = 'new-channel-id';
const {result: result2} = renderHook(() => useAccessControlAttributes('channel', newEntityId, true));
// Manually trigger a fetch
await act(async () => {
await result2.current.fetchAttributes(true); // Use forceRefresh to bypass cache
});
// Verify the API was called with the new entity ID
expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledWith(
mockServerUrl,
newEntityId,
);
// Verify the correct data was processed
expect(result2.current.attributeTags).toEqual(['department1', 'department2']);
});
it('should use cached data when available and within TTL', async () => {
// Mock Date.now to control time
const originalDateNow = Date.now;
const mockNow = jest.fn();
const currentTime = 1000;
mockNow.mockReturnValue(currentTime);
global.Date.now = mockNow;
try {
// Directly manipulate the cache instead of relying on the hook to populate it
// @ts-ignore - Accessing private module variable for testing
AccessControlAttributesModule.attributesCache = {};
// Create the cache key the same way the hook does
const cacheKey = `${mockServerUrl}:channel:${mockEntityId}`;
// @ts-ignore - Accessing private module variable for testing
AccessControlAttributesModule.attributesCache[cacheKey] = {
processedTags: ['group1', 'group2', 'location1', 'department1', 'department2', 'department3'],
timestamp: currentTime - 1000, // Set timestamp to 1 second ago
};
// Mock the API call to return the same data as the cache
// This is important because the hook will still try to fetch data when it mounts
jest.resetAllMocks();
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue({
attributes: mockAttributes,
});
// Render the hook - it should use the cache we just set
const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true));
// Wait a bit to ensure the hook has time to process
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
// Check that the cached data was used
expect(result.current.attributeTags).toEqual(['group1', 'group2', 'location1', 'department1', 'department2', 'department3']);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
// The hook will still make an API call when it mounts, even if the cache is populated
// So we just verify that the API call is made only once and that the hook uses the cached data
expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledTimes(1);
// Advance time beyond TTL
mockNow.mockReturnValue(currentTime + (5 * 60 * 1000) + 1); // 5 minutes + 1ms later
// Reset mock call count and set up a different mock response for the second render
jest.clearAllMocks();
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue({
attributes: {
differentData: ['should', 'not', 'be', 'used'],
},
});
// Render again - should fetch from API because cache expired
const {result: result2} = renderHook(() =>
useAccessControlAttributes('channel', mockEntityId, true),
);
// Wait for the hook to update
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledTimes(1);
expect(result2.current.attributeTags).toEqual(['should', 'not', 'be', 'used']);
} finally {
// Restore original Date.now
global.Date.now = originalDateNow;
}
});
});