diff --git a/src/frontend/apps/impress/jest.config.ts b/src/frontend/apps/impress/jest.config.ts index 52ef120d..05bfb0f3 100644 --- a/src/frontend/apps/impress/jest.config.ts +++ b/src/frontend/apps/impress/jest.config.ts @@ -9,6 +9,7 @@ const createJestConfig = nextJest({ const config: Config = { coverageProvider: 'v8', moduleNameMapper: { + '^@/docs/(.*)$': '/src/features/docs/$1', '^@/(.*)$': '/src/$1', }, setupFilesAfterEnv: ['/jest.setup.ts'], diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx new file mode 100644 index 00000000..0a20001d --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx @@ -0,0 +1,185 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; +import { useRouter } from 'next/router'; +import * as Y from 'yjs'; + +import { AppWrapper } from '@/tests/utils'; + +import useSaveDoc from '../useSaveDoc'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +jest.mock('@/docs/doc-versioning', () => ({ + KEY_LIST_DOC_VERSIONS: 'test-key-list-doc-versions', +})); + +jest.mock('@/docs/doc-management', () => ({ + useUpdateDoc: jest.requireActual('@/docs/doc-management/api/useUpdateDoc') + .useUpdateDoc, +})); + +describe('useSaveDoc', () => { + const mockRouterEvents = { + on: jest.fn(), + off: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + fetchMock.restore(); + + (useRouter as jest.Mock).mockReturnValue({ + events: mockRouterEvents, + }); + }); + + it('should setup event listeners on mount', () => { + const yDoc = new Y.Doc(); + const docId = 'test-doc-id'; + + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + + renderHook(() => useSaveDoc(docId, yDoc, true), { + wrapper: AppWrapper, + }); + + // Verify router event listeners are set up + expect(mockRouterEvents.on).toHaveBeenCalledWith( + 'routeChangeStart', + expect.any(Function), + ); + + // Verify window event listener is set up + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'beforeunload', + expect.any(Function), + ); + + addEventListenerSpy.mockRestore(); + }); + + it('should not save when canSave is false', async () => { + jest.useFakeTimers(); + const yDoc = new Y.Doc(); + const docId = 'test-doc-id'; + + fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', { + body: JSON.stringify({ + id: 'test-doc-id', + content: 'test-content', + title: 'test-title', + }), + }); + + renderHook(() => useSaveDoc(docId, yDoc, false), { + wrapper: AppWrapper, + }); + + act(() => { + // Trigger a local update + yDoc.getMap('test').set('key', 'value'); + }); + + act(() => { + // Now advance timers after state has updated + jest.advanceTimersByTime(61000); + }); + + await waitFor(() => { + expect(fetchMock.calls().length).toBe(0); + }); + + jest.useRealTimers(); + }); + + it('should save when there are local changes', async () => { + jest.useFakeTimers(); + const yDoc = new Y.Doc(); + const docId = 'test-doc-id'; + + fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', { + body: JSON.stringify({ + id: 'test-doc-id', + content: 'test-content', + title: 'test-title', + }), + }); + + renderHook(() => useSaveDoc(docId, yDoc, true), { + wrapper: AppWrapper, + }); + + act(() => { + // Trigger a local update + yDoc.getMap('test').set('key', 'value'); + }); + + act(() => { + // Now advance timers after state has updated + jest.advanceTimersByTime(61000); + }); + + await waitFor(() => { + expect(fetchMock.lastCall()?.[0]).toBe( + 'http://test.jest/api/v1.0/documents/test-doc-id/', + ); + }); + + jest.useRealTimers(); + }); + + it('should not save when there are no local changes', async () => { + jest.useFakeTimers(); + const yDoc = new Y.Doc(); + const docId = 'test-doc-id'; + + fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', { + body: JSON.stringify({ + id: 'test-doc-id', + content: 'test-content', + title: 'test-title', + }), + }); + + renderHook(() => useSaveDoc(docId, yDoc, true), { + wrapper: AppWrapper, + }); + + act(() => { + // Now advance timers after state has updated + jest.advanceTimersByTime(61000); + }); + + await waitFor(() => { + expect(fetchMock.calls().length).toBe(0); + }); + + jest.useRealTimers(); + }); + + it('should cleanup event listeners on unmount', () => { + const yDoc = new Y.Doc(); + const docId = 'test-doc-id'; + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), { + wrapper: AppWrapper, + }); + + unmount(); + + // Verify router event listeners are cleaned up + expect(mockRouterEvents.off).toHaveBeenCalledWith( + 'routeChangeStart', + expect.any(Function), + ); + + // Verify window event listener is cleaned up + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'beforeunload', + expect.any(Function), + ); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx index ad0569cf..8a5a41c0 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx @@ -1,5 +1,5 @@ import { useRouter } from 'next/router'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import * as Y from 'yjs'; import { useUpdateDoc } from '@/docs/doc-management/'; @@ -8,17 +8,16 @@ import { isFirefox } from '@/utils/userAgent'; import { toBase64 } from '../utils'; -const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => { +const SAVE_INTERVAL = 60000; + +const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => { const { mutate: updateDoc } = useUpdateDoc({ listInvalideQueries: [KEY_LIST_DOC_VERSIONS], + onSuccess: () => { + setIsLocalChange(false); + }, }); - const [initialDoc, setInitialDoc] = useState( - toBase64(Y.encodeStateAsUpdate(doc)), - ); - - useEffect(() => { - setInitialDoc(toBase64(Y.encodeStateAsUpdate(doc))); - }, [doc]); + const [isLocalChange, setIsLocalChange] = useState(false); /** * Update initial doc when doc is updated by other users, @@ -29,56 +28,34 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => { const onUpdate = ( _uintArray: Uint8Array, _pluginKey: string, - updatedDoc: Y.Doc, + _updatedDoc: Y.Doc, transaction: Y.Transaction, ) => { - if (!transaction.local) { - setInitialDoc(toBase64(Y.encodeStateAsUpdate(updatedDoc))); - } + setIsLocalChange(transaction.local ? true : false); }; - doc.on('update', onUpdate); + yDoc.on('update', onUpdate); return () => { - doc.off('update', onUpdate); + yDoc.off('update', onUpdate); }; - }, [doc]); - - /** - * Check if the doc has been updated and can be saved. - */ - const hasChanged = useCallback(() => { - const newDoc = toBase64(Y.encodeStateAsUpdate(doc)); - return initialDoc !== newDoc; - }, [doc, initialDoc]); - - const shouldSave = useCallback(() => { - return hasChanged() && canSave; - }, [canSave, hasChanged]); + }, [yDoc]); const saveDoc = useCallback(() => { - const newDoc = toBase64(Y.encodeStateAsUpdate(doc)); - setInitialDoc(newDoc); + if (!canSave || !isLocalChange) { + return; + } updateDoc({ id: docId, - content: newDoc, + content: toBase64(Y.encodeStateAsUpdate(yDoc)), }); - }, [doc, docId, updateDoc]); + }, [canSave, yDoc, docId, isLocalChange, updateDoc]); - const timeout = useRef(null); const router = useRouter(); useEffect(() => { - if (timeout.current) { - clearTimeout(timeout.current); - } - const onSave = (e?: Event) => { - if (!shouldSave()) { - return; - } - saveDoc(); /** @@ -94,21 +71,19 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => { }; // Save every minute - timeout.current = setInterval(onSave, 60000); + const timeout = setInterval(onSave, SAVE_INTERVAL); // Save when the user leaves the page addEventListener('beforeunload', onSave); // Save when the user navigates to another page router.events.on('routeChangeStart', onSave); return () => { - if (timeout.current) { - clearTimeout(timeout.current); - } + clearInterval(timeout); removeEventListener('beforeunload', onSave); router.events.off('routeChangeStart', onSave); }; - }, [router.events, saveDoc, shouldSave]); + }, [router.events, saveDoc]); }; export default useSaveDoc;