documentsSlice.uploads.test.ts14.8 KBView on GitHub
/**
 * Tests: documentsSlice — upload-specific actions.
 *
 * Builds a tiny standalone Zustand store that mounts only `createDocumentsSlice`
 * so we can exercise its slice actions without spinning up the full app store.
 *
 * Covers:
 *   - optimisticCreateAttachment inserts a temp node + an UploadAttempt
 *   - setUploadDocumentId swaps the temp id → real id in documents AND children-by-parent
 *   - clearUpload aborts the XHR (we mock abortUpload to verify) and removes
 *     the temp node and the chat-attachment index entry
 *   - attachToChat / consumeChatAttachments contract:
 *     - only `done` uploads are returned and removed
 *     - failed/in-flight uploads stay so the user can see/retry them
 *   - removeChatAttachment delegates to clearUpload
 *
 * Mocks:
 *   - `abortUpload` from uploadRunner — we just want to know it was invoked
 *     for in-flight cancels.
 */

import { create, type StateCreator } from 'zustand';
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

const mockAbortUpload = jest.fn();
jest.mock('@/modules/files/upload/uploadRunner', () => ({
  abortUpload: (...args: unknown[]) => mockAbortUpload(...args),
}));

import {
  createDocumentsSlice,
  type DocumentsSlice,
  type FileScope,
} from '@/modules/files/store/documentsSlice';

// Build a minimal store that satisfies the slice's StateCreator signature.
// CedarStore is huge but the slice only reaches into its own state, so
// casting to `unknown as DocumentsSlice` works for tests.
function makeStore() {
  return create<DocumentsSlice>()(
    devtools(
      immer((set, get, api) =>
        // The real createDocumentsSlice is typed against CedarStore. We cast
        // the set/get to whatever our minimal store provides — the slice
        // never touches other slices, so this is safe at test time.
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (createDocumentsSlice as unknown as any)(set, get, api),
      ) as unknown as StateCreator<DocumentsSlice, [['zustand/devtools', never]], [], DocumentsSlice>,
    ),
  );
}

const SCOPE: FileScope = { type: 'user', id: 'user-1' };

beforeEach(() => {
  mockAbortUpload.mockReset();
});

// ─── optimisticCreateAttachment ───────────────────────────────────────────────

describe('optimisticCreateAttachment', () => {
  it('inserts a temp node with documentType=custom and an UploadAttempt by default', () => {
    const store = makeStore();
    const { opId, tempId } = store
      .getState()
      .optimisticCreateAttachment({
        scope: SCOPE,
        parentId: null,
        filename: 'doc.pdf',
        mimeType: 'application/pdf',
        sizeBytes: 1024,
      });

    const state = store.getState();
    const tempNode = state.documents[tempId];
    expect(tempNode).toBeDefined();
    expect(tempNode.documentType).toBe('custom');
    expect(tempNode.title).toBe('doc.pdf');
    expect(tempNode.isOptimistic).toBe(true);

    const upload = state.uploads[opId];
    expect(upload).toBeDefined();
    expect(upload.tempId).toBe(tempId);
    expect(upload.status).toBe('validating');
    expect(upload.filename).toBe('doc.pdf');
    expect(upload.sizeBytes).toBe(1024);
  });

  it('supports explicit kb_item uploads', () => {
    const store = makeStore();
    const { opId, tempId } = store.getState().optimisticCreateAttachment({
      scope: SCOPE,
      parentId: null,
      documentType: 'kb_item',
      filename: 'deck.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 1024,
    });

    const state = store.getState();
    expect(state.documents[tempId].documentType).toBe('kb_item');
    expect(state.uploads[opId].documentType).toBe('kb_item');
  });

  it('inserts the temp id into the parent\'s childrenByParent list', () => {
    const store = makeStore();
    const { tempId } = store
      .getState()
      .optimisticCreateAttachment({
        scope: SCOPE,
        parentId: null,
        filename: 'doc.pdf',
        mimeType: 'application/pdf',
        sizeBytes: 1,
      });

    const children = store.getState().getChildren(SCOPE, null);
    expect(children).toContain(tempId);
  });

  it('rollback removes the temp node and the upload', () => {
    const store = makeStore();
    const { opId, tempId, rollback } = store
      .getState()
      .optimisticCreateAttachment({
        scope: SCOPE,
        parentId: null,
        filename: 'doc.pdf',
        mimeType: 'application/pdf',
        sizeBytes: 1,
      });

    rollback();

    expect(store.getState().documents[tempId]).toBeUndefined();
    expect(store.getState().uploads[opId]).toBeUndefined();
  });
});

// ─── setUploadDocumentId ──────────────────────────────────────────────────────

describe('setUploadDocumentId', () => {
  it('swaps temp id → real id in documents and in children arrays', () => {
    const store = makeStore();
    const { opId, tempId } = store
      .getState()
      .optimisticCreateAttachment({
        scope: SCOPE,
        parentId: null,
        filename: 'doc.pdf',
        mimeType: 'application/pdf',
        sizeBytes: 1,
      });

    store.getState().setUploadDocumentId(opId, 'real-doc-1');

    const state = store.getState();
    expect(state.documents[tempId]).toBeUndefined();
    expect(state.documents['real-doc-1']).toBeDefined();
    expect(state.documents['real-doc-1'].id).toBe('real-doc-1');
    expect(state.documents['real-doc-1'].isOptimistic).toBe(false);

    const children = state.getChildren(SCOPE, null);
    expect(children).not.toContain(tempId);
    expect(children).toContain('real-doc-1');

    // The upload entry retains the documentId for future consumeChatAttachments.
    expect(state.uploads[opId].documentId).toBe('real-doc-1');
    expect(state.uploads[opId].tempId).toBeUndefined();
  });

  it('is a no-op when the upload does not exist', () => {
    const store = makeStore();
    expect(() => store.getState().setUploadDocumentId('missing', 'real-x')).not.toThrow();
  });
});

// ─── clearUpload ──────────────────────────────────────────────────────────────

describe('clearUpload', () => {
  it('aborts the XHR (via abortUpload) and removes the temp node + upload entry', () => {
    const store = makeStore();
    const { opId, tempId } = store
      .getState()
      .optimisticCreateAttachment({
        scope: SCOPE,
        parentId: null,
        filename: 'doc.pdf',
        mimeType: 'application/pdf',
        sizeBytes: 1,
      });

    store.getState().clearUpload(opId);

    expect(mockAbortUpload).toHaveBeenCalledWith(opId);
    expect(store.getState().uploads[opId]).toBeUndefined();
    expect(store.getState().documents[tempId]).toBeUndefined();
  });

  it('removes a chat-attachment opId from chatAttachments[threadId]', () => {
    const store = makeStore();
    const { opId } = store.getState().attachToChat({
      threadId: 'thread-1',
      filename: 'doc.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 1,
    });
    expect(store.getState().chatAttachments['thread-1']).toContain(opId);

    store.getState().clearUpload(opId);

    expect(store.getState().chatAttachments['thread-1']).not.toContain(opId);
    expect(mockAbortUpload).toHaveBeenCalledWith(opId);
  });
});

// ─── attachToChat ─────────────────────────────────────────────────────────────

describe('attachToChat', () => {
  it('creates an UploadAttempt scoped to chat_thread and indexes it under threadId', () => {
    const store = makeStore();
    const { opId } = store.getState().attachToChat({
      threadId: 'thread-1',
      filename: 'doc.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 1,
    });

    const upload = store.getState().uploads[opId];
    expect(upload.scope).toEqual({ type: 'chat_thread', id: 'thread-1' });
    expect(upload.chatThreadId).toBe('thread-1');
    expect(upload.status).toBe('validating');
    expect(store.getState().chatAttachments['thread-1']).toEqual([opId]);
  });

  it('does NOT insert a tree node (chat scope is invisible to the tree)', () => {
    const store = makeStore();
    const { opId } = store.getState().attachToChat({
      threadId: 'thread-1',
      filename: 'doc.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 1,
    });
    // Should not push any temp id into the documents map.
    expect(Object.keys(store.getState().documents)).toEqual([]);
    expect(store.getState().uploads[opId].tempId).toBeUndefined();
  });

  it('appends to existing chatAttachments[threadId] (preserves order)', () => {
    const store = makeStore();
    const a = store.getState().attachToChat({
      threadId: 'thread-1',
      filename: 'a.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 1,
    });
    const b = store.getState().attachToChat({
      threadId: 'thread-1',
      filename: 'b.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 1,
    });
    expect(store.getState().chatAttachments['thread-1']).toEqual([a.opId, b.opId]);
  });

  it('rollback removes the chat-attachment entry', () => {
    const store = makeStore();
    const { opId, rollback } = store.getState().attachToChat({
      threadId: 'thread-1',
      filename: 'a.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 1,
    });
    rollback();
    expect(store.getState().uploads[opId]).toBeUndefined();
    expect(store.getState().chatAttachments['thread-1']).not.toContain(opId);
  });
});

// ─── consumeChatAttachments ───────────────────────────────────────────────────

describe('consumeChatAttachments', () => {
  it('returns documentIds of done uploads only and clears them from the index', () => {
    const store = makeStore();
    const a = store.getState().attachToChat({
      threadId: 'thread-1',
      filename: 'a.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 1,
    });
    const b = store.getState().attachToChat({
      threadId: 'thread-1',
      filename: 'b.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 1,
    });
    const c = store.getState().attachToChat({
      threadId: 'thread-1',
      filename: 'c.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 1,
    });

    // Set statuses: a is done with id, b is in-flight, c is failed.
    store.getState().setUploadStatus(a.opId, { status: 'done', documentId: 'real-a' });
    store.getState().setUploadStatus(b.opId, { status: 'uploading' });
    store
      .getState()
      .setUploadStatus(c.opId, {
        status: 'failed',
        error: { code: 'network', message: 'oops' },
      });

    const consumed = store.getState().consumeChatAttachments('thread-1');
    expect(consumed).toEqual([
      expect.objectContaining({ documentId: 'real-a' }),
    ]);

    // a is purged; b and c remain so the user can see / retry / cancel them.
    expect(store.getState().uploads[a.opId]).toBeUndefined();
    expect(store.getState().uploads[b.opId]).toBeDefined();
    expect(store.getState().uploads[c.opId]).toBeDefined();
    expect(store.getState().chatAttachments['thread-1']).toEqual([b.opId, c.opId]);
  });

  it('returns an empty array when threadId is unknown', () => {
    const store = makeStore();
    expect(store.getState().consumeChatAttachments('nope')).toEqual([]);
  });

  it('returns an empty array when no uploads have completed', () => {
    const store = makeStore();
    const a = store.getState().attachToChat({
      threadId: 'thread-1',
      filename: 'a.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 1,
    });
    expect(store.getState().consumeChatAttachments('thread-1')).toEqual([]);
    // Upload still tracked.
    expect(store.getState().uploads[a.opId]).toBeDefined();
  });
});

// ─── removeChatAttachment delegates to clearUpload ────────────────────────────

describe('removeChatAttachment', () => {
  it('aborts the in-flight XHR + removes the upload + drops it from the index', () => {
    const store = makeStore();
    const { opId } = store.getState().attachToChat({
      threadId: 'thread-1',
      filename: 'a.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 1,
    });

    store.getState().removeChatAttachment('thread-1', opId);

    expect(mockAbortUpload).toHaveBeenCalledWith(opId);
    expect(store.getState().uploads[opId]).toBeUndefined();
    expect(store.getState().chatAttachments['thread-1']).not.toContain(opId);
  });
});

// ─── getUpload / getChatAttachments selectors ────────────────────────────────

describe('selectors', () => {
  it('getUpload returns the UploadAttempt or undefined', () => {
    const store = makeStore();
    const { opId } = store.getState().attachToChat({
      threadId: 'thread-1',
      filename: 'a.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 1,
    });
    expect(store.getState().getUpload(opId)?.filename).toBe('a.pdf');
    expect(store.getState().getUpload('missing')).toBeUndefined();
  });

  it('getChatAttachments returns all uploads for the thread (in order)', () => {
    const store = makeStore();
    const a = store.getState().attachToChat({
      threadId: 'thread-1',
      filename: 'a.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 1,
    });
    const b = store.getState().attachToChat({
      threadId: 'thread-1',
      filename: 'b.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 1,
    });
    const list = store.getState().getChatAttachments('thread-1');
    expect(list.map((u) => u.opId)).toEqual([a.opId, b.opId]);
  });
});

// ─── setUploadStatus partial patches ─────────────────────────────────────────

describe('setUploadStatus', () => {
  it('merges a partial patch onto an existing upload', () => {
    const store = makeStore();
    const { opId } = store.getState().attachToChat({
      threadId: 'thread-1',
      filename: 'a.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 1,
    });
    store.getState().setUploadStatus(opId, { status: 'uploading', progress: 0.5 });
    expect(store.getState().uploads[opId].status).toBe('uploading');
    expect(store.getState().uploads[opId].progress).toBe(0.5);
    // Untouched fields preserved.
    expect(store.getState().uploads[opId].filename).toBe('a.pdf');
  });

  it('is a no-op for unknown opId', () => {
    const store = makeStore();
    expect(() => store.getState().setUploadStatus('missing', { status: 'done' })).not.toThrow();
  });
});