mattermost-mobile-upstream/app/utils/draft/sync.test.ts
Guillermo Vayá 6fdb18f6ea feat(drafts): Phase 3.1 — atomic local draft intent via outbox
Route every portable Draft mutation through the serialized mutateDraftAndOutbox
writer so local content and durable sync intent commit atomically. No network
behavior yet (workers/coordinator come later).

- Add draftContentFingerprint (pure cyrb53 hash over canonical content) to
  utils/draft/sync.ts — used to fingerprint a draft at DELETE-enqueue time.
- Add prepareDraftOutbox coalescing helper (upsert/delete/park/waitingForUpload/
  remove/staleCleanup) implementing the authoritative coalescing table, plus
  adoptLegacyDrafts (gen-1 pending upsert for non-empty, parked unsyncable_empty
  for empty; idempotent, team-scoped) in queries/servers/drafts.ts.
- Refactor actions/local/draft.ts: remove prepareRecordsOnly from the six
  portable mutations; central empty-transition (potentiallyDispatched -> DELETE
  with keepLocal when attachments remain; otherwise park or destroy+remove);
  upload transitions (waiting_for_upload for in-progress, upsert on completion
  only when the message can POST, else park); priority/BoR park on empty message;
  markdown image metadata stays device-local (no outbox); removeDraft fingerprints
  and enqueues a non-keepLocal DELETE. Team scope stamped from the channel.

Empty-message drafts never produce a pending upsert (empty == server delete);
they park as unsyncable_empty until a genuine message edit flips them to pending.

Checks: npm run tsc clean; eslint --quiet clean; 129 Jest tests pass across the
draft action/sync/query suites plus post-handler and drafts client regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 14:27:55 +02:00

253 lines
9.1 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import TestHelper from '@test/test_helper';
import {buildDraftUpsertRequest, draftContentFingerprint, isSyncableDraft, normalizeServerDraft, reconstructDraftBoRConfig} from './sync';
const baseServerDraft: DraftApi = {
create_at: 100,
update_at: 200,
delete_at: 0,
user_id: 'user_id',
channel_id: 'channel_id',
root_id: '',
message: 'server message',
type: '',
props: {from_webhook: 'true'},
file_ids: ['file1', 'file2'],
};
describe('normalizeServerDraft (server -> mobile)', () => {
it('should map update_at to serverUpdateAt, pass props through, and copy file_ids exactly', () => {
const result = normalizeServerDraft(baseServerDraft);
expect(result.channelId).toBe('channel_id');
expect(result.rootId).toBe('');
expect(result.message).toBe('server message');
expect(result.serverUpdateAt).toBe(200);
expect(result.props).toEqual({from_webhook: 'true'});
expect(result.fileIds).toEqual(['file1', 'file2']);
expect(result.fileIds).toHaveLength(2);
});
it('should copy file_ids exactly even when metadata.files is missing, and default files to empty', () => {
const result = normalizeServerDraft(baseServerDraft);
expect(result.fileIds).toEqual(['file1', 'file2']);
expect(result.files).toEqual([]);
expect(result.files).toHaveLength(0);
});
it('should hydrate files from metadata.files', () => {
const file1 = TestHelper.fakeFileInfo({id: 'file1'});
const file2 = TestHelper.fakeFileInfo({id: 'file2'});
const serverDraft: DraftApi = {
...baseServerDraft,
metadata: {files: [file1, file2]},
};
const result = normalizeServerDraft(serverDraft);
expect(result.files).toEqual([file1, file2]);
expect(result.files).toHaveLength(2);
});
it('should map priority into metadata.priority', () => {
const priority: PostPriority = {priority: 'urgent', requested_ack: true};
const serverDraft: DraftApi = {...baseServerDraft, priority};
const result = normalizeServerDraft(serverDraft);
expect(result.metadata?.priority).toEqual(priority);
});
it('should leave metadata undefined when there is no priority or borConfig', () => {
const result = normalizeServerDraft(baseServerDraft);
expect(result.metadata).toBeUndefined();
});
it('should reconstruct borConfig for a burn_on_read draft when durations are valid', () => {
const serverDraft: DraftApi = {...baseServerDraft, type: 'burn_on_read'};
const result = normalizeServerDraft(serverDraft, {borDurationSeconds: 30, borMaximumTimeToLiveSeconds: 300});
expect(result.type).toBe('burn_on_read');
expect(result.metadata?.borConfig).toEqual({
enabled: true,
borDurationSeconds: 30,
borMaximumTimeToLiveSeconds: 300,
});
});
it('should not set an enabled borConfig for a burn_on_read draft when durations are missing (fail closed)', () => {
const serverDraft: DraftApi = {...baseServerDraft, type: 'burn_on_read'};
const result = normalizeServerDraft(serverDraft);
expect(result.metadata?.borConfig).toBeUndefined();
});
});
describe('isSyncableDraft', () => {
const draft = {
channelId: 'channel_id',
rootId: '',
type: '' as PostTypesUserCreatable,
props: null,
fileIds: [],
};
it('should be true when the draft has message content', () => {
expect(isSyncableDraft({...draft, message: 'hello'})).toBe(true);
});
it('should be false for an empty message even with attachments and priority', () => {
const emptyDraft = {
...draft,
message: '',
fileIds: ['file1'],
metadata: {priority: {priority: 'urgent'} as PostPriority},
};
expect(isSyncableDraft(emptyDraft)).toBe(false);
});
});
describe('buildDraftUpsertRequest (mobile -> server)', () => {
it('should send only the allowed fields, with file_ids from fileIds and priority from metadata', () => {
const draft = {
channelId: 'channel_id',
rootId: 'root_id',
message: 'draft message',
type: '' as PostTypesUserCreatable,
props: {from_webhook: 'true'},
fileIds: ['file1'],
metadata: {priority: {priority: 'important'} as PostPriority},
};
const request = buildDraftUpsertRequest(draft);
expect(request).toEqual({
channel_id: 'channel_id',
root_id: 'root_id',
message: 'draft message',
type: '',
props: {from_webhook: 'true'},
file_ids: ['file1'],
priority: {priority: 'important'},
});
});
it('should never serialize local-only file fields', () => {
const draft = {
channelId: 'channel_id',
rootId: '',
message: 'draft message',
type: '' as PostTypesUserCreatable,
props: null,
fileIds: ['file1'],
metadata: {
files: [TestHelper.fakeFileInfo({id: 'file1', localPath: '/tmp/x', clientId: 'c1', bytesRead: 5, failed: true})],
},
};
const request = buildDraftUpsertRequest(draft);
const serialized = JSON.stringify(request);
expect(serialized).not.toContain('localPath');
expect(serialized).not.toContain('clientId');
expect(serialized).not.toContain('bytesRead');
expect(serialized).not.toContain('failed');
expect(request?.file_ids).toEqual(['file1']);
});
it('should return null for an empty message so it never POSTs', () => {
const draft = {
channelId: 'channel_id',
rootId: '',
message: '',
type: '' as PostTypesUserCreatable,
props: null,
fileIds: [],
};
expect(buildDraftUpsertRequest(draft)).toBeNull();
});
});
describe('draftContentFingerprint', () => {
const base = {
message: 'hello',
type: '' as PostTypesUserCreatable,
props: {from_webhook: 'true'} as DraftProps,
fileIds: ['file1', 'file2'],
priority: {priority: 'urgent'} as PostPriority,
};
it('should produce the same hash for identical content', () => {
expect(draftContentFingerprint(base)).toBe(draftContentFingerprint({...base}));
});
it('should never return the raw content', () => {
const fingerprint = draftContentFingerprint(base);
expect(fingerprint).not.toContain('hello');
expect(fingerprint).not.toContain('file1');
expect(fingerprint).not.toContain('urgent');
expect(fingerprint.length).toBeGreaterThan(0);
});
it('should change when the message changes', () => {
expect(draftContentFingerprint({...base, message: 'world'})).not.toBe(draftContentFingerprint(base));
});
it('should change when the fileIds change', () => {
expect(draftContentFingerprint({...base, fileIds: ['file1']})).not.toBe(draftContentFingerprint(base));
});
it('should change when the props change', () => {
expect(draftContentFingerprint({...base, props: {from_webhook: 'false'}})).not.toBe(draftContentFingerprint(base));
});
it('should change when the priority changes', () => {
expect(draftContentFingerprint({...base, priority: {priority: 'important'} as PostPriority})).not.toBe(draftContentFingerprint(base));
});
it('should be independent of prop key order and fileId order', () => {
const a = draftContentFingerprint({
...base,
props: {a: '1', b: '2'} as unknown as DraftProps,
fileIds: ['file1', 'file2'],
});
const b = draftContentFingerprint({
...base,
props: {b: '2', a: '1'} as unknown as DraftProps,
fileIds: ['file2', 'file1'],
});
expect(a).toBe(b);
});
});
describe('reconstructDraftBoRConfig', () => {
it('should return an enabled config for a burn_on_read type with valid durations', () => {
expect(reconstructDraftBoRConfig('burn_on_read', {borDurationSeconds: 30, borMaximumTimeToLiveSeconds: 300})).toEqual({
enabled: true,
borDurationSeconds: 30,
borMaximumTimeToLiveSeconds: 300,
});
});
it('should return null (fail closed) for a burn_on_read type when durations are missing', () => {
expect(reconstructDraftBoRConfig('burn_on_read', undefined)).toBeNull();
});
it('should return null (fail closed) for a burn_on_read type when durations are not positive', () => {
expect(reconstructDraftBoRConfig('burn_on_read', {borDurationSeconds: 0, borMaximumTimeToLiveSeconds: 300})).toBeNull();
});
it('should return null for a non burn_on_read type', () => {
expect(reconstructDraftBoRConfig('', {borDurationSeconds: 30, borMaximumTimeToLiveSeconds: 300})).toBeNull();
});
});