canvas-slice.test.ts8.6 KBView on GitHub
/**
 * Canvas Slice — upsertCanvas dirty check & owner filter clearing
 *
 * Tests the two canvas-slice behaviors that were fixed:
 * 1. upsertCanvas must not overwrite a canvas that has unsaved local changes (dirty).
 * 2. Removing the primaryUser filter must atomically clear viewConfig.ownerUserIds.
 */

import { create } from 'zustand';
import type { Canvas } from '@/modules/canvas/types/canvas-types';
import type { ConversationViewConfig } from '@/modules/canvas/types/canvas-types';
import type { CanvasFilterSortConfiguration } from '@/modules/cedar-os/src/store/messages/MessageTypes';

// ─── Minimal test canvas factory ─────────────────────────────────────────────

function makeCanvas(id: string, overrides: Partial<Canvas> = {}): Canvas {
  return {
    id,
    type: 'conversationCanvas',
    title: 'Test Canvas',
    data: {},
    primaryOwner: 'user1',
    visibility: 'private',
    homeViewOrder: 1,
    viewConfig: { type: 'conversationCanvas' },
    createdAt: '2024-01-01T00:00:00Z',
    updatedAt: '2024-01-01T00:00:00Z',
    ...overrides,
  } as Canvas;
}

// ─── Minimal store that mirrors the canvasSlice logic under test ───────────────

interface TestStore {
  canvasesById: Record<string, Canvas>;
  dirtyCanvasIds: Set<string>;

  /** Mirrors canvasSlice.upsertCanvas — skips dirty canvases */
  upsertCanvas: (canvas: Canvas) => void;

  /** Mirrors canvasSlice.updateCanvasViewConfig — marks canvas dirty */
  updateCanvasViewConfig: (canvasId: string, viewConfig: ConversationViewConfig) => void;

  /** Mirrors what saveCanvasViewConfig does after the API call succeeds */
  clearDirty: (canvasId: string) => void;
}

const useStore = create<TestStore>((set, get) => ({
  canvasesById: {},
  dirtyCanvasIds: new Set<string>(),

  upsertCanvas: (canvas) => {
    const state = get();
    // Don't overwrite a canvas that has pending local changes (dirty).
    if (state.dirtyCanvasIds.has(canvas.id)) return;
    set({ canvasesById: { ...state.canvasesById, [canvas.id]: canvas } });
  },

  updateCanvasViewConfig: (canvasId, viewConfig) => {
    const state = get();
    const canvas = state.canvasesById[canvasId];
    if (!canvas) return;
    const dirtyCanvasIds = new Set(state.dirtyCanvasIds);
    dirtyCanvasIds.add(canvasId);
    set({
      canvasesById: { ...state.canvasesById, [canvasId]: { ...canvas, viewConfig } },
      dirtyCanvasIds,
    });
  },

  clearDirty: (canvasId) => {
    const state = get();
    const dirtyCanvasIds = new Set(state.dirtyCanvasIds);
    dirtyCanvasIds.delete(canvasId);
    set({ dirtyCanvasIds });
  },
}));

const resetStore = () => {
  useStore.setState({ canvasesById: {}, dirtyCanvasIds: new Set() });
};

// ─── Tests ───────────────────────────────────────────────────────────────────

describe('upsertCanvas — dirty check', () => {
  beforeEach(resetStore);

  it('updates a clean canvas', () => {
    const canvas = makeCanvas('c1', { title: 'Original' });
    useStore.getState().upsertCanvas(canvas);
    expect(useStore.getState().canvasesById['c1'].title).toBe('Original');

    useStore.getState().upsertCanvas({ ...canvas, title: 'Updated' });
    expect(useStore.getState().canvasesById['c1'].title).toBe('Updated');
  });

  it('does NOT overwrite a dirty canvas (race condition guard)', () => {
    // Seed canvas, then mark it dirty via a local edit
    useStore.getState().upsertCanvas(makeCanvas('c1', { title: 'Original' }));
    useStore.getState().updateCanvasViewConfig('c1', {
      type: 'conversationCanvas',
      filterSortConfiguration: { status: { filter: { selected: ['active'] } } },
    });

    // Simulate background refetch arriving with stale server data
    useStore.getState().upsertCanvas(makeCanvas('c1', { title: 'Server version' }));

    // Dirty canvas should be untouched
    expect(useStore.getState().canvasesById['c1'].title).toBe('Original');
    expect(
      (useStore.getState().canvasesById['c1'].viewConfig as ConversationViewConfig)
        ?.filterSortConfiguration?.status?.filter?.selected,
    ).toEqual(['active']);
  });

  it('resumes accepting updates after dirty flag is cleared (save completes)', () => {
    useStore.getState().upsertCanvas(makeCanvas('c1', { title: 'Original' }));
    useStore.getState().updateCanvasViewConfig('c1', { type: 'conversationCanvas' });

    // Save completes → clear dirty flag
    useStore.getState().clearDirty('c1');

    // Now a server update should land normally
    useStore.getState().upsertCanvas(makeCanvas('c1', { title: 'Post-save server version' }));
    expect(useStore.getState().canvasesById['c1'].title).toBe('Post-save server version');
  });

  it('skips upsert for canvas not yet in store when another canvas is dirty', () => {
    // c1 is dirty; c2 is unrelated and should be insertable
    useStore.getState().upsertCanvas(makeCanvas('c1'));
    useStore.getState().updateCanvasViewConfig('c1', { type: 'conversationCanvas' });

    useStore.getState().upsertCanvas(makeCanvas('c2', { title: 'New canvas' }));
    expect(useStore.getState().canvasesById['c2'].title).toBe('New canvas');
  });
});

// ─── Owner filter clearing ────────────────────────────────────────────────────

/**
 * Mirrors the handleRemoveColumn logic for 'primaryUser' in FilterSortConfigurationRow.
 * The fix ensures ownerUserIds is atomically cleared alongside the filter.
 */
function removeColumnFromViewConfig(
  currentViewConfig: ConversationViewConfig,
  columnId: string,
): ConversationViewConfig {
  const currentFilterSort: CanvasFilterSortConfiguration =
    currentViewConfig.filterSortConfiguration ?? {};

  const newFilterSort: CanvasFilterSortConfiguration = {
    ...currentFilterSort,
    [columnId]: {
      ...currentFilterSort[columnId],
      sort: currentFilterSort[columnId]?.sort
        ? { ...currentFilterSort[columnId].sort!, active: false }
        : undefined,
      filter: undefined,
    },
  };

  return {
    ...currentViewConfig,
    filterSortConfiguration: newFilterSort,
    ...(columnId === 'primaryUser' ? { ownerUserIds: [] } : {}),
  };
}

describe('handleRemoveColumn — owner filter clearing', () => {
  it('clears ownerUserIds when removing primaryUser filter', () => {
    const viewConfig: ConversationViewConfig = {
      type: 'conversationCanvas',
      ownerUserIds: ['user-abc', 'user-xyz'],
      filterSortConfiguration: {
        primaryUser: { filter: { selected: ['user-abc', 'user-xyz'] } },
      },
    };

    const result = removeColumnFromViewConfig(viewConfig, 'primaryUser');

    expect(result.ownerUserIds).toEqual([]);
    expect(result.filterSortConfiguration?.primaryUser?.filter).toBeUndefined();
  });

  it('does NOT touch ownerUserIds when removing a non-owner column', () => {
    const viewConfig: ConversationViewConfig = {
      type: 'conversationCanvas',
      ownerUserIds: ['user-abc'],
      filterSortConfiguration: {
        status: { filter: { selected: ['active'] } },
      },
    };

    const result = removeColumnFromViewConfig(viewConfig, 'status');

    expect(result.ownerUserIds).toEqual(['user-abc']);
    expect(result.filterSortConfiguration?.status?.filter).toBeUndefined();
  });

  it('deactivates an active sort on the removed column without clearing other sorts', () => {
    const viewConfig: ConversationViewConfig = {
      type: 'conversationCanvas',
      filterSortConfiguration: {
        status: { sort: { active: true, direction: 'asc', priority: 0 } },
        priority: { sort: { active: true, direction: 'desc', priority: 1 } },
      },
    };

    const result = removeColumnFromViewConfig(viewConfig, 'status');

    expect(result.filterSortConfiguration?.status?.sort?.active).toBe(false);
    expect(result.filterSortConfiguration?.priority?.sort?.active).toBe(true);
  });

  it('resets ownerUserIds to [] (current user default) after primaryUser removal', () => {
    const viewConfig: ConversationViewConfig = {
      type: 'conversationCanvas',
      ownerUserIds: ['user-abc'],
      filterSortConfiguration: { primaryUser: { filter: { selected: ['user-abc'] } } },
    };

    const result = removeColumnFromViewConfig(viewConfig, 'primaryUser');

    // [] means "current user only" (same as undefined) — resets to default scope
    expect(result.ownerUserIds).toStrictEqual([]);
    expect(Array.isArray(result.ownerUserIds)).toBe(true);
  });
});