mattermost-mobile-upstream/app/components/post_draft/editor_guard.test.ts
Guillermo Vayá 5cb2fbedb8 feat(drafts): Phase 3.3 — composer race guards + fail-closed BoR send guard
Guard the composer against draft resurrection now that removeDraft enqueues a
durable DELETE, and fail closed on unreconstructable burn-on-read sends.

- Add per-composer editor_guard module (keyed by serverUrl:channelId:rootId):
  tracks genuine unsaved edits + a generation bumped on Send/Delete. Shared by
  the save site and the invalidation sites; avoids prop-drilling.
- post_input: route blur/background/unmount through guardedSave, which no-ops
  when the editor is clean and discards its write (via a captured-generation
  staleness check) if Send/Delete invalidated the editor mid-save. Genuine
  typing marks dirty; an external/remote value change resets the baseline clean;
  unmount clears the entry.
- draft_handler.clearDraft and deleteDraftConfirmation invalidate the editor so a
  pending lifecycle save cannot recreate a just-sent/deleted draft.
- handleDraftUpdate discards stale markdown-image metadata when the editor
  generation changed during the async parse.
- handle_send_message: fail-closed burn-on-read guard on both send paths — an
  enabled BoR send with invalid/missing durations shows a localized error, keeps
  the draft, and never creates a downgraded plain post. Adds one en.json string.

Checks: npm run tsc clean; eslint --quiet clean; 126 Jest tests pass (editor
guard, handle_send_message incl. BoR fail-closed, draft actions, draft utils).

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

112 lines
3.4 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
buildEditorKey,
captureEditorGeneration,
clearEditor,
invalidateEditor,
isEditorGenerationStale,
markEditorEdited,
markEditorSaved,
resetEditorBaseline,
shouldSaveEditor,
} from './editor_guard';
describe('editor_guard', () => {
const serverUrl = 'https://server.com';
const channelId = 'channel-id';
const rootId = '';
let key: string;
beforeEach(() => {
key = buildEditorKey(serverUrl, channelId, rootId);
// Ensure a fresh entry for each test.
clearEditor(key);
});
it('should build a composite key from serverUrl, channelId and rootId', () => {
expect(buildEditorKey('a', 'b', 'c')).toBe('a:b:c');
});
it('should default to clean, non-stale state on first access', () => {
expect(shouldSaveEditor(key)).toBe(false);
expect(captureEditorGeneration(key)).toBe(0);
});
it('should make shouldSaveEditor true after a genuine edit', () => {
markEditorEdited(key);
expect(shouldSaveEditor(key)).toBe(true);
});
it('should clear dirty flag and set baseline after markEditorSaved', () => {
markEditorEdited(key);
markEditorSaved(key, 'saved value');
expect(shouldSaveEditor(key)).toBe(false);
});
it('should clear dirty flag without signalling an edit on resetEditorBaseline', () => {
markEditorEdited(key);
resetEditorBaseline(key, 'external value');
expect(shouldSaveEditor(key)).toBe(false);
});
it('should bump generation and clear dirty flag on invalidateEditor', () => {
markEditorEdited(key);
const before = captureEditorGeneration(key);
invalidateEditor(key);
expect(captureEditorGeneration(key)).toBe(before + 1);
// Resurrection guard: a subsequent lifecycle save must be a no-op.
expect(shouldSaveEditor(key)).toBe(false);
});
it('should make a captured generation stale after invalidateEditor', () => {
const captured = captureEditorGeneration(key);
expect(isEditorGenerationStale(key, captured)).toBe(false);
invalidateEditor(key);
expect(isEditorGenerationStale(key, captured)).toBe(true);
});
it('should treat a captured generation as stale when the key is absent', () => {
const captured = captureEditorGeneration(key);
clearEditor(key);
expect(isEditorGenerationStale(key, captured)).toBe(true);
});
it('should remove state on clearEditor so a fresh entry is created afterwards', () => {
markEditorEdited(key);
invalidateEditor(key);
expect(captureEditorGeneration(key)).toBe(1);
clearEditor(key);
// Fresh entry: generation reset to 0, clean.
expect(captureEditorGeneration(key)).toBe(0);
expect(shouldSaveEditor(key)).toBe(false);
});
it('should keep distinct keys independent', () => {
const otherKey = buildEditorKey(serverUrl, 'other-channel', rootId);
clearEditor(otherKey);
markEditorEdited(key);
invalidateEditor(key);
expect(shouldSaveEditor(otherKey)).toBe(false);
expect(captureEditorGeneration(otherKey)).toBe(0);
markEditorEdited(otherKey);
expect(shouldSaveEditor(otherKey)).toBe(true);
expect(shouldSaveEditor(key)).toBe(false);
clearEditor(otherKey);
});
});