mattermost-mobile-upstream/app/actions/websocket/system.test.ts
Guillermo Vayá 62c694adeb feat(drafts): Phase 5 — WebSocket + lifecycle triggers make sync live
Wires the DraftSyncManager's reconcile/WS entry points (previously defined
but never called in production) into the app's WebSocket and lifecycle flow.
Design choice: WS draft events are treated as CHANGE SIGNALS that trigger an
authoritative GET reconcile of the current team, not applied directly — this
reuses Phase 4's matrix/scope/absence/observation-fence, and the server's
silent empty-upsert delete (no event) forces GET-reconciliation regardless.
The current-team GET also returns the user's DM/GM drafts, so it converges
them too; other teams' channel drafts converge on team switch.

Triggers:
- draft_created / draft_deleted WS events -> enqueue -> reconcile current team
  (constants + app/actions/websocket/draft.ts handler + event dispatch cases).
- Reconnect/first-connect: requestReconcile in doReconnect after entry batches.
- Team switch: requestReconcile in handleTeamChange (before the tablet early
  return, so both paths fire).
- Foreground: WebsocketManager forwards app-active to DraftSyncManager.onForeground
  (covers a socket that stayed open, so no reconnect fired). No second AppState
  listener is added.
- Capability change: handleCapabilityChange after config (AllowSyncedDrafts) and
  after sync_drafts preference create/update/delete batches.

Also adds a central empty-team guard in requestReconcile (the GET is per-team).

Deferred (noted for a follow-up): Global Drafts stale-on-open reconcile (touches
a screen, needs on-device verification); direct-apply of buffered WS events (the
buffer is retained, reserved for that future optimization).

tsc + eslint clean; new/updated unit tests across manager, ws handler, event
dispatch, reconnect, team switch, config/preference capability. All affected
suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 16:50:19 +02:00

187 lines
6.3 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {updateDmGmDisplayName} from '@actions/local/channel';
import {reconcilePersistenceFlag} from '@actions/local/ephemeral_mode/wipe';
import {storeConfig} from '@actions/local/systems';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import DraftSyncManager from '@managers/draft_sync_manager';
import {getConfig, getLicense} from '@queries/servers/system';
import {handleLicenseChangedEvent, handleConfigChangedEvent} from './system';
import type ServerDataOperator from '@database/operator/server_data_operator';
jest.mock('@actions/local/channel');
jest.mock('@actions/local/ephemeral_mode/wipe');
jest.mock('@actions/local/systems');
jest.mock('@database/manager');
jest.mock('@queries/servers/system');
jest.mock('@managers/draft_sync_manager', () => ({
__esModule: true,
default: {handleCapabilityChange: jest.fn()},
}));
describe('WebSocket System Actions', () => {
const serverUrl = 'baseHandler.test.com';
let operator: ServerDataOperator;
let handleSystem: jest.SpyInstance;
beforeEach(async () => {
jest.clearAllMocks();
await DatabaseManager.init([serverUrl]);
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
handleSystem = jest.spyOn(operator, 'handleSystem');
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
jest.restoreAllMocks();
});
describe('handleLicenseChangedEvent', () => {
it('should handle missing operator', async () => {
DatabaseManager.serverDatabases = {};
const msg = {
data: {
license: {
LockTeammateNameDisplay: true,
},
},
} as WebSocketMessage;
await handleLicenseChangedEvent(serverUrl, msg);
expect(handleSystem).not.toHaveBeenCalled();
});
it('should handle license update with no display name change', async () => {
const mockLicense = {
LockTeammateNameDisplay: false,
};
jest.mocked(getLicense).mockResolvedValue({
LockTeammateNameDisplay: 'false',
} as ClientLicense);
const msg = {
data: {
license: mockLicense,
},
} as WebSocketMessage;
await handleLicenseChangedEvent(serverUrl, msg);
expect(handleSystem).toHaveBeenCalledWith({
systems: [{
id: SYSTEM_IDENTIFIERS.LICENSE,
value: JSON.stringify(mockLicense),
}],
prepareRecordsOnly: false,
});
expect(updateDmGmDisplayName).not.toHaveBeenCalled();
});
it('should handle license update with display name change', async () => {
const mockLicense = {
LockTeammateNameDisplay: true,
};
jest.mocked(getLicense).mockResolvedValue({
LockTeammateNameDisplay: 'false',
} as ClientLicense);
const msg = {
data: {
license: mockLicense,
},
} as WebSocketMessage;
await handleLicenseChangedEvent(serverUrl, msg);
expect(handleSystem).toHaveBeenCalled();
expect(updateDmGmDisplayName).toHaveBeenCalledWith(serverUrl);
});
});
describe('handleConfigChangedEvent', () => {
it('should handle missing operator', async () => {
DatabaseManager.serverDatabases = {};
const msg = {
data: {
config: {
LockTeammateNameDisplay: true,
},
},
} as WebSocketMessage;
await handleConfigChangedEvent(serverUrl, msg);
expect(storeConfig).not.toHaveBeenCalled();
});
it('should handle config update with no display name change', async () => {
const mockConfig = {
LockTeammateNameDisplay: false,
};
jest.mocked(getConfig).mockResolvedValue({
LockTeammateNameDisplay: 'false',
} as ClientConfig);
const msg = {
data: {
config: mockConfig,
},
} as WebSocketMessage;
await handleConfigChangedEvent(serverUrl, msg);
expect(storeConfig).toHaveBeenCalledWith(serverUrl, mockConfig);
expect(updateDmGmDisplayName).not.toHaveBeenCalled();
// AllowSyncedDrafts may have changed with the config: re-evaluate draft-sync capability.
expect(DraftSyncManager.handleCapabilityChange).toHaveBeenCalledWith(serverUrl);
});
it('should handle config update with display name change', async () => {
const mockConfig = {
LockTeammateNameDisplay: true,
};
jest.mocked(getConfig).mockResolvedValue({
LockTeammateNameDisplay: 'false',
} as ClientConfig);
const msg = {
data: {
config: mockConfig,
},
} as WebSocketMessage;
await handleConfigChangedEvent(serverUrl, msg);
expect(storeConfig).toHaveBeenCalledWith(serverUrl, mockConfig);
expect(updateDmGmDisplayName).toHaveBeenCalledWith(serverUrl);
});
it('should reconcile the persistence flag against the new config so a live MEM-cleanup change clears zero-persistence', async () => {
const mockConfig = {
MobileEphemeralModeEnabled: 'true',
MobileEphemeralModeAutoCacheCleanupDays: '5',
};
jest.mocked(getConfig).mockResolvedValue({
MobileEphemeralModeEnabled: 'true',
MobileEphemeralModeAutoCacheCleanupDays: '0',
} as ClientConfig);
const msg = {data: {config: mockConfig}} as WebSocketMessage;
await handleConfigChangedEvent(serverUrl, msg);
expect(reconcilePersistenceFlag).toHaveBeenCalledWith(serverUrl, mockConfig);
});
});
});