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();
});
});