Fetch playbooks when we reconnect and we are on a channel (#8993)

* Fetch playbooks when we reconnect and we are on a channel

* Fix test

* Address feedback
This commit is contained in:
Daniel Espino García 2025-07-18 11:37:43 +02:00 committed by GitHub
parent 394b441874
commit 5a034aa51f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 272 additions and 83 deletions

View file

@ -13,7 +13,7 @@ import {loadConfigAndCalls} from '@calls/actions/calls';
import {isSupportedServerCalls} from '@calls/utils';
import DatabaseManager from '@database/manager';
import AppsManager from '@managers/apps_manager';
import {updatePlaybooksVersion} from '@playbooks/actions/remote/version';
import {handlePlaybookReconnect} from '@playbooks/actions/websocket/reconnect';
import {getActiveServerUrl} from '@queries/app/servers';
import {getLastPostInThread} from '@queries/servers/post';
import {getConfig, getCurrentChannelId, getCurrentTeamId, setLastFullSync} from '@queries/servers/system';
@ -49,7 +49,7 @@ jest.mock('@utils/helpers', () => ({
isTablet: jest.fn().mockReturnValue(false),
}));
jest.mock('@playbooks/actions/remote/version');
jest.mock('@playbooks/actions/websocket/reconnect');
describe('WebSocket Index Actions', () => {
const serverUrl = 'baseHandler.test.com';
@ -104,7 +104,7 @@ describe('WebSocket Index Actions', () => {
expect(setLastFullSync).toHaveBeenCalled();
expect(loadConfigAndCalls).toHaveBeenCalled();
expect(deferredAppEntryActions).toHaveBeenCalled();
expect(updatePlaybooksVersion).toHaveBeenCalledWith(serverUrl);
expect(handlePlaybookReconnect).toHaveBeenCalledWith(serverUrl);
});
it('should handle error when server database not found', async () => {
@ -159,7 +159,7 @@ describe('WebSocket Index Actions', () => {
expect(openAllUnreadChannels).toHaveBeenCalled();
expect(dataRetentionCleanup).toHaveBeenCalled();
expect(AppsManager.refreshAppBindings).toHaveBeenCalled();
expect(updatePlaybooksVersion).toHaveBeenCalledWith(serverUrl);
expect(handlePlaybookReconnect).toHaveBeenCalledWith(serverUrl);
});
it('should fetch posts for channel screen', async () => {

View file

@ -18,7 +18,7 @@ import {isSupportedServerCalls} from '@calls/utils';
import {Screens} from '@constants';
import DatabaseManager from '@database/manager';
import AppsManager from '@managers/apps_manager';
import {updatePlaybooksVersion} from '@playbooks/actions/remote/version';
import {handlePlaybookReconnect} from '@playbooks/actions/websocket/reconnect';
import {getActiveServerUrl} from '@queries/app/servers';
import {getLastPostInThread} from '@queries/servers/post';
import {
@ -92,8 +92,7 @@ async function doReconnect(serverUrl: string, groupLabel?: BaseRequestGroupLabel
const license = await getLicense(database);
const config = await getConfig(database);
// Set the version of the playbooks plugin to the systems table
updatePlaybooksVersion(serverUrl);
handlePlaybookReconnect(serverUrl);
if (isSupportedServerCalls(config?.Version)) {
loadConfigAndCalls(serverUrl, currentUserId, groupLabel);

View file

@ -11,7 +11,6 @@ import WebSocketClient from '@client/websocket';
import DatabaseManager from '@database/manager';
import {getCurrentUserId} from '@queries/servers/system';
import {queryAllUsers} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
import TestHelper from '@test/test_helper';
import {logError} from '@utils/log';
@ -31,7 +30,6 @@ jest.mock('@database/manager');
jest.mock('@queries/servers/system');
jest.mock('@queries/servers/user');
jest.mock('@utils/log');
jest.mock('@store/ephemeral_store');
describe('WebsocketManager', () => {
let manager: typeof WebsocketManager;
@ -125,17 +123,6 @@ describe('WebsocketManager', () => {
});
});
describe('proper callbacks set', () => {
it('should remove playbooks when the reconnect callback is called', () => {
const client = manager.createClient(mockServerUrl, mockToken);
expect(client).toBeDefined();
expect(client.setReconnectCallback).toHaveBeenCalled();
jest.mocked(client.setReconnectCallback).mock.calls[0][0]();
expect(EphemeralStore.clearChannelPlaybooksSynced).toHaveBeenCalled();
});
});
describe('connection handling', () => {
beforeEach(async () => {
await manager.init(mockCredentials);

View file

@ -16,7 +16,6 @@ import {General} from '@constants';
import DatabaseManager from '@database/manager';
import {getCurrentUserId} from '@queries/servers/system';
import {queryAllUsers} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
import {toMilliseconds} from '@utils/datetime';
import {isMainActivity} from '@utils/helpers';
import {logError} from '@utils/log';
@ -180,11 +179,6 @@ class WebsocketManagerSingleton {
this.startPeriodicStatusUpdates(serverUrl);
this.getConnectedSubject(serverUrl).next('connected');
// Clear playbooks synced state on reconnect.
// This is done to avoid clearing it on spotty connections
// with reliable websockets.
EphemeralStore.clearChannelPlaybooksSynced(serverUrl);
const error = await handleReconnect(serverUrl);
if (error) {
this.getClient(serverUrl)?.close(false);

View file

@ -7,6 +7,7 @@ import NetworkManager from '@managers/network_manager';
import {updateLastPlaybookRunsFetchAt} from '@playbooks/actions/local/channel';
import {handlePlaybookRuns} from '@playbooks/actions/local/run';
import {getLastPlaybookRunsFetchAt} from '@playbooks/database/queries/run';
import EphemeralStore from '@store/ephemeral_store';
import TestHelper from '@test/test_helper';
import {fetchPlaybookRunsForChannel, fetchFinishedRunsForChannel} from './runs';
@ -118,6 +119,32 @@ describe('fetchPlaybookRunsForChannel', () => {
expect(result.runs).toEqual([mockPlaybookRun]);
expect(handlePlaybookRuns).not.toHaveBeenCalled();
});
it('should update the channel in the ephemeral store', async () => {
mockClient.fetchPlaybookRuns.mockResolvedValueOnce({
items: [mockPlaybookRun],
has_more: false,
});
EphemeralStore.clearChannelPlaybooksSynced(serverUrl);
expect(EphemeralStore.getChannelPlaybooksSynced(serverUrl, channelId)).toBe(false);
await fetchPlaybookRunsForChannel(serverUrl, channelId);
expect(EphemeralStore.getChannelPlaybooksSynced(serverUrl, channelId)).toBe(true);
});
it('should not update the channel in the ephemeral store if fetchOnly is true', async () => {
mockClient.fetchPlaybookRuns.mockResolvedValueOnce({
items: [mockPlaybookRun],
has_more: false,
});
EphemeralStore.clearChannelPlaybooksSynced(serverUrl);
expect(EphemeralStore.getChannelPlaybooksSynced(serverUrl, channelId)).toBe(false);
await fetchPlaybookRunsForChannel(serverUrl, channelId, true);
expect(EphemeralStore.getChannelPlaybooksSynced(serverUrl, channelId)).toBe(false);
});
});
describe('fetchFinishedRunsForChannel', () => {

View file

@ -9,6 +9,7 @@ import {updateLastPlaybookRunsFetchAt} from '@playbooks/actions/local/channel';
import {handlePlaybookRuns} from '@playbooks/actions/local/run';
import {getLastPlaybookRunsFetchAt} from '@playbooks/database/queries/run';
import {getMaxRunUpdateAt} from '@playbooks/utils/run';
import EphemeralStore from '@store/ephemeral_store';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
@ -50,6 +51,7 @@ export const fetchPlaybookRunsForChannel = async (serverUrl: string, channelId:
updateLastPlaybookRunsFetchAt(serverUrl, channelId, getMaxRunUpdateAt(allRuns));
if (!fetchOnly) {
EphemeralStore.setChannelPlaybooksSynced(serverUrl, channelId);
handlePlaybookRuns(serverUrl, allRuns, false, true);
}

View file

@ -0,0 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {fetchPlaybookRunsForChannel} from '@playbooks/actions/remote/runs';
import {updatePlaybooksVersion} from '@playbooks/actions/remote/version';
import {fetchIsPlaybooksEnabled} from '@playbooks/database/queries/version';
import {getCurrentChannelId} from '@queries/servers/system';
import EphemeralStore from '@store/ephemeral_store';
import NavigationStore from '@store/navigation_store';
import {handlePlaybookReconnect} from './reconnect';
const serverUrl = 'test-server.com';
jest.mock('@playbooks/actions/remote/runs');
jest.mock('@playbooks/actions/remote/version');
jest.mock('@playbooks/database/queries/version');
jest.mock('@queries/servers/system');
describe('handlePlaybookReconnect', () => {
const mockCurrentChannelId = 'channel-123';
beforeEach(async () => {
jest.clearAllMocks();
await DatabaseManager.init([serverUrl]);
jest.mocked(updatePlaybooksVersion).mockResolvedValue({data: true});
jest.mocked(fetchIsPlaybooksEnabled).mockResolvedValue(false);
jest.mocked(getCurrentChannelId).mockResolvedValue(mockCurrentChannelId);
jest.mocked(fetchPlaybookRunsForChannel).mockResolvedValue({runs: []});
});
afterEach(async () => {
await DatabaseManager.deleteServerDatabase(serverUrl);
});
it('should return early when database is not found', async () => {
const clearSpy = jest.spyOn(EphemeralStore, 'clearChannelPlaybooksSynced');
const getScreensSpy = jest.spyOn(NavigationStore, 'getScreensInStack');
await DatabaseManager.deleteServerDatabase(serverUrl);
await handlePlaybookReconnect(serverUrl);
expect(clearSpy).not.toHaveBeenCalled();
expect(updatePlaybooksVersion).not.toHaveBeenCalled();
expect(getScreensSpy).not.toHaveBeenCalled();
});
it('should clear channel playbooks synced state', async () => {
const clearSpy = jest.spyOn(EphemeralStore, 'clearChannelPlaybooksSynced');
await handlePlaybookReconnect(serverUrl);
expect(clearSpy).toHaveBeenCalledWith(serverUrl);
expect(clearSpy).toHaveBeenCalledTimes(1);
});
it('should update playbooks version', async () => {
await handlePlaybookReconnect(serverUrl);
expect(updatePlaybooksVersion).toHaveBeenCalledWith(serverUrl);
expect(updatePlaybooksVersion).toHaveBeenCalledTimes(1);
});
it('should not fetch playbook runs when not on channel screen', async () => {
const getScreensSpy = jest.spyOn(NavigationStore, 'getScreensInStack').mockReturnValue([Screens.HOME, Screens.THREAD]);
await handlePlaybookReconnect(serverUrl);
expect(getScreensSpy).toHaveBeenCalled();
expect(fetchIsPlaybooksEnabled).not.toHaveBeenCalled();
expect(getCurrentChannelId).not.toHaveBeenCalled();
expect(fetchPlaybookRunsForChannel).not.toHaveBeenCalled();
});
it('should not fetch playbook runs when playbooks are disabled', async () => {
const getScreensSpy = jest.spyOn(NavigationStore, 'getScreensInStack').mockReturnValue([Screens.HOME, Screens.CHANNEL, Screens.THREAD]);
jest.mocked(fetchIsPlaybooksEnabled).mockResolvedValue(false);
await handlePlaybookReconnect(serverUrl);
expect(getScreensSpy).toHaveBeenCalled();
expect(fetchIsPlaybooksEnabled).toHaveBeenCalled();
expect(getCurrentChannelId).not.toHaveBeenCalled();
expect(fetchPlaybookRunsForChannel).not.toHaveBeenCalled();
});
it('should fetch playbook runs when on channel screen and playbooks are enabled', async () => {
const getScreensSpy = jest.spyOn(NavigationStore, 'getScreensInStack').mockReturnValue([Screens.CHANNEL]);
jest.mocked(fetchIsPlaybooksEnabled).mockResolvedValue(true);
await handlePlaybookReconnect(serverUrl);
expect(getScreensSpy).toHaveBeenCalled();
expect(fetchIsPlaybooksEnabled).toHaveBeenCalled();
expect(getCurrentChannelId).toHaveBeenCalled();
expect(fetchPlaybookRunsForChannel).toHaveBeenCalledWith(serverUrl, mockCurrentChannelId);
expect(fetchPlaybookRunsForChannel).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {fetchPlaybookRunsForChannel} from '@playbooks/actions/remote/runs';
import {updatePlaybooksVersion} from '@playbooks/actions/remote/version';
import {fetchIsPlaybooksEnabled} from '@playbooks/database/queries/version';
import {getCurrentChannelId} from '@queries/servers/system';
import EphemeralStore from '@store/ephemeral_store';
import NavigationStore from '@store/navigation_store';
import {logDebug} from '@utils/log';
export async function handlePlaybookReconnect(serverUrl: string) {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
// Clear playbooks synced state on reconnect.
// This is done to avoid clearing it on spotty connections
// with reliable websockets.
EphemeralStore.clearChannelPlaybooksSynced(serverUrl);
// Set the version of the playbooks plugin to the systems table
const updateResult = await updatePlaybooksVersion(serverUrl);
if (updateResult.error) {
logDebug('Error updating playbooks version on reconnect', updateResult.error);
}
if (NavigationStore.getScreensInStack().includes(Screens.CHANNEL)) {
const isPlaybooksEnabled = await fetchIsPlaybooksEnabled(database);
if (isPlaybooksEnabled) {
const currentChannelId = await getCurrentChannelId(database);
const fetchResult = await fetchPlaybookRunsForChannel(serverUrl, currentChannelId);
if (fetchResult.error) {
logDebug('Error fetching playbook runs on reconnect', fetchResult.error);
}
}
}
}

View file

@ -5,7 +5,7 @@ import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {MINIMUM_MAJOR_VERSION, MINIMUM_MINOR_VERSION, MINIMUM_PATCH_VERSION} from '@playbooks/constants/version';
import {observeIsPlaybooksEnabled} from './version';
import {fetchIsPlaybooksEnabled, observeIsPlaybooksEnabled} from './version';
import type ServerDataOperator from '@database/operator/server_data_operator';
@ -151,4 +151,75 @@ describe('Playbook Version Queries', () => {
expect(subscriptionNext).toHaveBeenCalledWith(false);
});
});
describe('fetchIsPlaybooksEnabled', () => {
it('should return false when no playbooks version is set', async () => {
const result = await fetchIsPlaybooksEnabled(operator.database);
expect(result).toBe(false);
});
it(`should return true when playbooks version meets minimum requirements (${MINIMUM_VERSION})`, async () => {
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: MINIMUM_VERSION}],
prepareRecordsOnly: false,
});
const result = await fetchIsPlaybooksEnabled(operator.database);
expect(result).toBe(true);
});
it('should return true when playbooks version has higher major version', async () => {
const higherVersion = `${MINIMUM_MAJOR_VERSION + 1}.0.0`;
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: higherVersion}],
prepareRecordsOnly: false,
});
const result = await fetchIsPlaybooksEnabled(operator.database);
expect(result).toBe(true);
});
it('should return true when playbooks version has higher minor version', async () => {
const higherVersion = `${MINIMUM_MAJOR_VERSION}.${MINIMUM_MINOR_VERSION + 1}.${MINIMUM_PATCH_VERSION}`;
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: higherVersion}],
prepareRecordsOnly: false,
});
const result = await fetchIsPlaybooksEnabled(operator.database);
expect(result).toBe(true);
});
it('should return true when playbooks version has higher patch version', async () => {
const higherVersion = `${MINIMUM_MAJOR_VERSION}.${MINIMUM_MINOR_VERSION}.${MINIMUM_PATCH_VERSION + 1}`;
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: higherVersion}],
prepareRecordsOnly: false,
});
const result = await fetchIsPlaybooksEnabled(operator.database);
expect(result).toBe(true);
});
it('should handle empty version string', async () => {
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: ''}],
prepareRecordsOnly: false,
});
const result = await fetchIsPlaybooksEnabled(operator.database);
expect(result).toBe(false);
});
it('should return false when playbooks version is below minimum', async () => {
const belowVersion = `${MINIMUM_MAJOR_VERSION - 1}.${MINIMUM_MINOR_VERSION}.${MINIMUM_PATCH_VERSION}`;
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: belowVersion}],
prepareRecordsOnly: false,
});
const result = await fetchIsPlaybooksEnabled(operator.database);
expect(result).toBe(false);
});
});
});

View file

@ -11,17 +11,32 @@ import {isMinimumServerVersion} from '@utils/helpers';
import type SystemModel from '@typings/database/models/servers/system';
function queryPlaybooksVersion(database: Database) {
return database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).query(
Q.where('id', SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION),
);
}
function isPlaybooksEnabledFromSystemModel(systems: SystemModel[]) {
const version = systems[0]?.value;
if (!version) {
return false;
}
return isMinimumServerVersion(version, MINIMUM_MAJOR_VERSION, MINIMUM_MINOR_VERSION, MINIMUM_PATCH_VERSION);
}
export async function fetchIsPlaybooksEnabled(database: Database) {
const systems = await queryPlaybooksVersion(database).fetch();
return isPlaybooksEnabledFromSystemModel(systems);
}
export function observeIsPlaybooksEnabled(database: Database) {
return database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).query(
Q.where('id', SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION),
).observeWithColumns(['value']).pipe(
switchMap((systems) => {
const version = systems[0]?.value;
if (!version) {
return of$(false);
}
return of$(isMinimumServerVersion(version, MINIMUM_MAJOR_VERSION, MINIMUM_MINOR_VERSION, MINIMUM_PATCH_VERSION));
return of$(isPlaybooksEnabledFromSystemModel(systems));
}),
);
}

View file

@ -128,31 +128,8 @@ describe('ChannelHeader', () => {
expect(goToPlaybookRun).not.toHaveBeenCalled();
});
it('should set the ephemeral store when we fetch the playbook runs for the channel', async () => {
const ephemeralGetSpy = jest.spyOn(EphemeralStore, 'getChannelPlaybooksSynced');
const ephemeralSetSpy = jest.spyOn(EphemeralStore, 'setChannelPlaybooksSynced');
const props = getBaseProps();
props.isPlaybooksEnabled = true;
ephemeralGetSpy.mockReturnValue(false);
jest.mocked(fetchPlaybookRunsForChannel).mockResolvedValue({
runs: [],
});
renderWithIntl(<ChannelHeader {...props}/>);
await waitFor(() => {
expect(ephemeralGetSpy).toHaveBeenCalledWith(serverUrl, 'channel-id');
expect(fetchPlaybookRunsForChannel).toHaveBeenCalledWith(serverUrl, 'channel-id');
expect(ephemeralSetSpy).toHaveBeenCalledWith(serverUrl, 'channel-id');
});
});
it('should not fetch runs when playbooks are disabled', async () => {
const ephemeralGetSpy = jest.spyOn(EphemeralStore, 'getChannelPlaybooksSynced');
const ephemeralSetSpy = jest.spyOn(EphemeralStore, 'setChannelPlaybooksSynced');
const props = getBaseProps();
props.isPlaybooksEnabled = false;
@ -162,14 +139,12 @@ describe('ChannelHeader', () => {
await waitFor(() => {
expect(ephemeralGetSpy).not.toHaveBeenCalled();
expect(ephemeralSetSpy).not.toHaveBeenCalled();
expect(fetchPlaybookRunsForChannel).not.toHaveBeenCalled();
});
});
it('should not fetch runs when we already have the runs synced', async () => {
const ephemeralGetSpy = jest.spyOn(EphemeralStore, 'getChannelPlaybooksSynced');
const ephemeralSetSpy = jest.spyOn(EphemeralStore, 'setChannelPlaybooksSynced');
const props = getBaseProps();
props.isPlaybooksEnabled = true;
@ -180,28 +155,7 @@ describe('ChannelHeader', () => {
await waitFor(() => {
expect(ephemeralGetSpy).toHaveBeenCalledWith(serverUrl, 'channel-id');
expect(ephemeralSetSpy).not.toHaveBeenCalled();
expect(fetchPlaybookRunsForChannel).not.toHaveBeenCalled();
});
});
it('should not set the ephemeral store when there is an error fetching the runs', async () => {
const ephemeralGetSpy = jest.spyOn(EphemeralStore, 'getChannelPlaybooksSynced');
const ephemeralSetSpy = jest.spyOn(EphemeralStore, 'setChannelPlaybooksSynced');
const props = getBaseProps();
props.isPlaybooksEnabled = true;
ephemeralGetSpy.mockReturnValue(false);
jest.mocked(fetchPlaybookRunsForChannel).mockResolvedValue({error: new Error('Error fetching runs')});
renderWithIntl(<ChannelHeader {...props}/>);
await waitFor(() => {
expect(ephemeralGetSpy).toHaveBeenCalledWith(serverUrl, 'channel-id');
expect(ephemeralSetSpy).not.toHaveBeenCalled();
expect(fetchPlaybookRunsForChannel).toHaveBeenCalledWith(serverUrl, 'channel-id');
});
});
});

View file

@ -303,10 +303,7 @@ const ChannelHeader = ({
useEffect(() => {
const asyncEffect = async () => {
if (isPlaybooksEnabled && !EphemeralStore.getChannelPlaybooksSynced(serverUrl, channelId)) {
const res = await fetchPlaybookRunsForChannel(serverUrl, channelId);
if (!('error' in res)) {
EphemeralStore.setChannelPlaybooksSynced(serverUrl, channelId);
}
await fetchPlaybookRunsForChannel(serverUrl, channelId);
}
};
asyncEffect();