diff --git a/ui/web/components/AuthForm.tsx b/ui/web/components/AuthForm.tsx index 477704b9abf..5a4110dd8d0 100644 --- a/ui/web/components/AuthForm.tsx +++ b/ui/web/components/AuthForm.tsx @@ -23,6 +23,8 @@ export default function AuthForm({ setLoading(true) try { await onTestLogin() + } catch { + // Handled by parent or toast } finally { setLoading(false) } @@ -32,6 +34,8 @@ export default function AuthForm({ setLoading(true) try { await onSkipAuth() + } catch { + // Handled by parent or toast } finally { setLoading(false) } @@ -41,6 +45,8 @@ export default function AuthForm({ setLoading(true) try { await onGoogleAuth() + } catch { + // Handled by parent or toast } finally { setLoading(false) } diff --git a/ui/web/tests/unit/adapters/networkStatus.test.ts b/ui/web/tests/unit/adapters/networkStatus.test.ts new file mode 100644 index 00000000000..2b671a3e7bb --- /dev/null +++ b/ui/web/tests/unit/adapters/networkStatus.test.ts @@ -0,0 +1,115 @@ +import { webNetworkStatus } from '@ui/web/adapters/networkStatus' + +describe('webNetworkStatus', () => { + describe('in browser environment', () => { + let addEventListenerSpy: jest.SpyInstance + let removeEventListenerSpy: jest.SpyInstance + + beforeEach(() => { + addEventListenerSpy = jest.spyOn(window, 'addEventListener') + removeEventListenerSpy = jest.spyOn(window, 'removeEventListener') + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('isOnline', () => { + it('returns true when navigator.onLine is true', () => { + jest.spyOn(navigator, 'onLine', 'get').mockReturnValue(true) + expect(webNetworkStatus.isOnline()).toBe(true) + }) + + it('returns false when navigator.onLine is false', () => { + jest.spyOn(navigator, 'onLine', 'get').mockReturnValue(false) + expect(webNetworkStatus.isOnline()).toBe(false) + }) + }) + + describe('subscribe', () => { + it('adds event listeners for online and offline events', () => { + const callback = jest.fn() + const unsubscribe = webNetworkStatus.subscribe(callback) + + expect(addEventListenerSpy).toHaveBeenCalledWith('online', expect.any(Function)) + expect(addEventListenerSpy).toHaveBeenCalledWith('offline', expect.any(Function)) + + unsubscribe() + }) + + it('calls callback(true) when window dispatches online event', () => { + const callback = jest.fn() + const unsubscribe = webNetworkStatus.subscribe(callback) + + window.dispatchEvent(new Event('online')) + + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith(true) + + unsubscribe() + }) + + it('calls callback(false) when window dispatches offline event', () => { + const callback = jest.fn() + const unsubscribe = webNetworkStatus.subscribe(callback) + + window.dispatchEvent(new Event('offline')) + + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith(false) + + unsubscribe() + }) + + it('removes event listeners when unsubscribe function is called', () => { + const callback = jest.fn() + const unsubscribe = webNetworkStatus.subscribe(callback) + + unsubscribe() + + expect(removeEventListenerSpy).toHaveBeenCalledWith('online', expect.any(Function)) + expect(removeEventListenerSpy).toHaveBeenCalledWith('offline', expect.any(Function)) + + // Dispatching events after unsubscribe should not invoke callback + window.dispatchEvent(new Event('online')) + window.dispatchEvent(new Event('offline')) + + expect(callback).not.toHaveBeenCalled() + }) + + it('supports multiple independent subscribers', () => { + const callback1 = jest.fn() + const callback2 = jest.fn() + + const unsubscribe1 = webNetworkStatus.subscribe(callback1) + const unsubscribe2 = webNetworkStatus.subscribe(callback2) + + window.dispatchEvent(new Event('offline')) + + expect(callback1).toHaveBeenCalledWith(false) + expect(callback2).toHaveBeenCalledWith(false) + + // Unsubscribe only callback1 + unsubscribe1() + + window.dispatchEvent(new Event('online')) + + expect(callback1).toHaveBeenCalledTimes(1) + expect(callback2).toHaveBeenCalledTimes(2) + expect(callback2).toHaveBeenLastCalledWith(true) + + unsubscribe2() + }) + + it('handles multiple unsubscribe calls safely', () => { + const callback = jest.fn() + const unsubscribe = webNetworkStatus.subscribe(callback) + + expect(() => { + unsubscribe() + unsubscribe() + }).not.toThrow() + }) + }) + }) +}) diff --git a/ui/web/tests/unit/components/AuthForm.test.tsx b/ui/web/tests/unit/components/AuthForm.test.tsx new file mode 100644 index 00000000000..62d7aafb1e2 --- /dev/null +++ b/ui/web/tests/unit/components/AuthForm.test.tsx @@ -0,0 +1,132 @@ +import React from "react" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" + +import AuthForm from "@/components/AuthForm" + +describe("AuthForm", () => { + const defaultProps = { + onTestLogin: jest.fn(), + onSkipAuth: jest.fn(), + onGoogleAuth: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("renders Google OAuth button by default and hides test auth options", () => { + render() + + expect(screen.getByRole("button", { name: /continue with google/i })).toBeTruthy() + expect(screen.queryByText(/or test the app/i)).toBeNull() + expect(screen.queryByRole("button", { name: /test login/i })).toBeNull() + expect(screen.queryByRole("button", { name: /skip authentication/i })).toBeNull() + }) + + it("renders test auth options and divider when enableTestAuth is true", () => { + render() + + expect(screen.getByRole("button", { name: /continue with google/i })).toBeTruthy() + expect(screen.getByText(/or test the app/i)).toBeTruthy() + expect(screen.getByRole("button", { name: /test login \(persistent\)/i })).toBeTruthy() + expect(screen.getByRole("button", { name: /skip authentication \(quick test\)/i })).toBeTruthy() + }) + + it("handles Google OAuth button click and manages loading state during pending execution", async () => { + let resolveGoogleAuth!: () => void + const googlePromise = new Promise((resolve) => { + resolveGoogleAuth = resolve + }) + const onGoogleAuthMock = jest.fn().mockReturnValue(googlePromise) + + render() + + const googleBtn = screen.getByRole("button", { name: /continue with google/i }) as HTMLButtonElement + const testLoginBtn = screen.getByRole("button", { name: /test login/i }) as HTMLButtonElement + const skipAuthBtn = screen.getByRole("button", { name: /skip authentication/i }) as HTMLButtonElement + + expect(googleBtn.disabled).toBe(false) + expect(testLoginBtn.disabled).toBe(false) + expect(skipAuthBtn.disabled).toBe(false) + + fireEvent.click(googleBtn) + + expect(onGoogleAuthMock).toHaveBeenCalledTimes(1) + expect(googleBtn.disabled).toBe(true) + expect(testLoginBtn.disabled).toBe(true) + expect(skipAuthBtn.disabled).toBe(true) + + resolveGoogleAuth() + + await waitFor(() => { + expect(googleBtn.disabled).toBe(false) + }) + + expect(testLoginBtn.disabled).toBe(false) + expect(skipAuthBtn.disabled).toBe(false) + }) + + it("handles Test Login button click and manages loading state", async () => { + let resolveTestLogin!: () => void + const testLoginPromise = new Promise((resolve) => { + resolveTestLogin = resolve + }) + const onTestLoginMock = jest.fn().mockReturnValue(testLoginPromise) + + render() + + const testLoginBtn = screen.getByRole("button", { name: /test login/i }) as HTMLButtonElement + + fireEvent.click(testLoginBtn) + + expect(onTestLoginMock).toHaveBeenCalledTimes(1) + expect(testLoginBtn.disabled).toBe(true) + + resolveTestLogin() + + await waitFor(() => { + expect(testLoginBtn.disabled).toBe(false) + }) + }) + + it("handles Skip Authentication button click and manages loading state", async () => { + let resolveSkipAuth!: () => void + const skipAuthPromise = new Promise((resolve) => { + resolveSkipAuth = resolve + }) + const onSkipAuthMock = jest.fn().mockReturnValue(skipAuthPromise) + + render() + + const skipAuthBtn = screen.getByRole("button", { name: /skip authentication/i }) as HTMLButtonElement + + fireEvent.click(skipAuthBtn) + + expect(onSkipAuthMock).toHaveBeenCalledTimes(1) + expect(skipAuthBtn.disabled).toBe(true) + + resolveSkipAuth() + + await waitFor(() => { + expect(skipAuthBtn.disabled).toBe(false) + }) + }) + + it("resets loading state when authentication action fails or throws error", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}) + const onGoogleAuthMock = jest.fn().mockRejectedValue(new Error("Google auth failed")) + + render() + + const googleBtn = screen.getByRole("button", { name: /continue with google/i }) as HTMLButtonElement + + fireEvent.click(googleBtn) + + await waitFor(() => { + expect(googleBtn.disabled).toBe(false) + }) + + expect(onGoogleAuthMock).toHaveBeenCalledTimes(1) + consoleSpy.mockRestore() + }) +}) diff --git a/ui/web/tests/unit/components/EditorMenuBar.test.tsx b/ui/web/tests/unit/components/EditorMenuBar.test.tsx new file mode 100644 index 00000000000..e8dd4ed5be7 --- /dev/null +++ b/ui/web/tests/unit/components/EditorMenuBar.test.tsx @@ -0,0 +1,593 @@ +import * as React from "react" +import { render, fireEvent, waitFor } from "@testing-library/react" +import type { Editor } from "@tiptap/react" +import { EditorMenuBar } from "@ui/web/components/EditorMenuBar" +import { browser } from "@ui/web/adapters/browser" + +// Polyfill JSDOM missing methods for Radix UI components +if (typeof window !== "undefined") { + if (!window.HTMLElement.prototype.scrollIntoView) { + window.HTMLElement.prototype.scrollIntoView = jest.fn() + } + if (!window.HTMLElement.prototype.hasPointerCapture) { + window.HTMLElement.prototype.hasPointerCapture = jest.fn() + } + if (!window.HTMLElement.prototype.setPointerCapture) { + window.HTMLElement.prototype.setPointerCapture = jest.fn() + } + if (!window.HTMLElement.prototype.releasePointerCapture) { + window.HTMLElement.prototype.releasePointerCapture = jest.fn() + } + if (!window.ResizeObserver) { + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver + } +} + +type MockEditorOptions = { + activeMarks?: Record + canSink?: boolean + canLift?: boolean + textColor?: string +} + +function createMockEditor(options: MockEditorOptions = {}) { + const runMock = jest.fn() + + const chainObj: Record = {} + const chainMethods = [ + "focus", + "toggleBold", + "toggleItalic", + "toggleUnderline", + "toggleStrike", + "toggleHighlight", + "setColor", + "setFontFamily", + "setFontSize", + "setHeading", + "setParagraph", + "setHorizontalRule", + "toggleBulletList", + "toggleOrderedList", + "toggleTaskList", + "setLink", + "setImage", + "setTextAlign", + "sinkListItem", + "liftListItem", + "toggleSuperscript", + "toggleSubscript", + "unsetAllMarks", + "clearNodes", + ] + + chainMethods.forEach((method) => { + chainObj[method] = jest.fn().mockImplementation(() => chainObj) + }) + chainObj.run = runMock + + // Mirrors TipTap's overloaded editor.isActive signatures: + // object argument for attributes (e.g. textAlign), name + attributes for nodes (e.g. heading levels), and plain string name for marks (e.g. bold). + const isActiveMock = jest.fn((name: string | Record, attributes?: Record) => { + if (typeof name === "object") { + const key = Object.keys(name)[0] + const val = name[key] + return options.activeMarks?.[`${key}:${val}`] ?? false + } + if (attributes && typeof attributes === "object") { + const key = Object.keys(attributes)[0] + const val = attributes[key] + return options.activeMarks?.[`${name}:${key}:${val}`] ?? false + } + return options.activeMarks?.[name] ?? false + }) + + const getAttributesMock = jest.fn((name: string) => { + if (name === "textStyle") { + return { color: options.textColor || "#000000" } + } + return {} + }) + + const canMock = jest.fn().mockReturnValue({ + sinkListItem: jest.fn().mockReturnValue(options.canSink ?? false), + liftListItem: jest.fn().mockReturnValue(options.canLift ?? false), + }) + + const editor = { + chain: jest.fn().mockReturnValue(chainObj), + isActive: isActiveMock, + getAttributes: getAttributesMock, + can: canMock, + } + + return { + editor: editor as unknown as Editor, + chainObj, + runMock, + isActiveMock, + getAttributesMock, + canMock, + } +} + +describe("EditorMenuBar", () => { + const defaultProps = { + historyState: { canUndo: true, canRedo: true }, + onUndo: jest.fn(), + onRedo: jest.fn(), + hasSelection: true, + onApplyMarkdown: jest.fn(), + spellcheckEnabled: true, + onToggleSpellcheck: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("Rendering & Null state", () => { + it("renders null when editor is null", () => { + const { container } = render( + + ) + expect(container.firstChild).toBeNull() + }) + + it("renders toolbar buttons when editor is provided", () => { + const { editor } = createMockEditor() + const { container } = render( + + ) + expect(container.firstChild).not.toBeNull() + expect(container.querySelector('[data-cy="undo-button"]')).not.toBeNull() + expect(container.querySelector('[data-cy="redo-button"]')).not.toBeNull() + }) + }) + + describe("History actions", () => { + it("disables undo button when historyState.canUndo is false", () => { + const { editor } = createMockEditor() + const { container } = render( + + ) + const undoBtn = container.querySelector('[data-cy="undo-button"]') as HTMLButtonElement + expect(undoBtn.disabled).toBe(true) + }) + + it("triggers onUndo when undo button is clicked", () => { + const { editor } = createMockEditor() + const onUndo = jest.fn() + const { container } = render( + + ) + const undoBtn = container.querySelector('[data-cy="undo-button"]') as HTMLButtonElement + fireEvent.click(undoBtn) + expect(onUndo).toHaveBeenCalledTimes(1) + }) + + it("disables redo button when historyState.canRedo is false", () => { + const { editor } = createMockEditor() + const { container } = render( + + ) + const redoBtn = container.querySelector('[data-cy="redo-button"]') as HTMLButtonElement + expect(redoBtn.disabled).toBe(true) + }) + + it("triggers onRedo when redo button is clicked", () => { + const { editor } = createMockEditor() + const onRedo = jest.fn() + const { container } = render( + + ) + const redoBtn = container.querySelector('[data-cy="redo-button"]') as HTMLButtonElement + fireEvent.click(redoBtn) + expect(onRedo).toHaveBeenCalledTimes(1) + }) + }) + + describe("Inline formatting commands", () => { + it("triggers toggleBold command on bold button click", () => { + const { editor, chainObj, runMock } = createMockEditor() + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="bold-button"]') as HTMLButtonElement + fireEvent.click(btn) + expect(editor.chain).toHaveBeenCalled() + expect(chainObj.focus).toHaveBeenCalled() + expect(chainObj.toggleBold).toHaveBeenCalled() + expect(runMock).toHaveBeenCalled() + }) + + it("triggers toggleItalic command on italic button click", () => { + const { editor, chainObj, runMock } = createMockEditor() + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="italic-button"]') as HTMLButtonElement + fireEvent.click(btn) + expect(chainObj.toggleItalic).toHaveBeenCalled() + expect(runMock).toHaveBeenCalled() + }) + + it("triggers toggleUnderline command on underline button click", () => { + const { editor, chainObj, runMock } = createMockEditor() + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="underline-button"]') as HTMLButtonElement + fireEvent.click(btn) + expect(chainObj.toggleUnderline).toHaveBeenCalled() + expect(runMock).toHaveBeenCalled() + }) + + it("triggers toggleStrike command on strike button click", () => { + const { editor, chainObj, runMock } = createMockEditor() + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="strike-button"]') as HTMLButtonElement + fireEvent.click(btn) + expect(chainObj.toggleStrike).toHaveBeenCalled() + expect(runMock).toHaveBeenCalled() + }) + + it("triggers toggleHighlight command on highlight button click", () => { + const { editor, chainObj, runMock } = createMockEditor() + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="highlight-button"]') as HTMLButtonElement + fireEvent.click(btn) + expect(chainObj.toggleHighlight).toHaveBeenCalled() + expect(runMock).toHaveBeenCalled() + }) + + it("displays active formatting states", () => { + const { editor } = createMockEditor({ + activeMarks: { + bold: true, + italic: true, + }, + }) + const { container } = render( + + ) + const boldBtn = container.querySelector('[data-cy="bold-button"]') + const italicBtn = container.querySelector('[data-cy="italic-button"]') + const underlineBtn = container.querySelector('[data-cy="underline-button"]') + + expect(boldBtn?.getAttribute("class")).toContain("bg-secondary") + expect(italicBtn?.getAttribute("class")).toContain("bg-secondary") + expect(underlineBtn?.getAttribute("class")).not.toContain("bg-secondary") + }) + }) + + describe("Headings & Paragraph formatting", () => { + it.each([1, 2, 3])("triggers setHeading level %i", (level) => { + const { editor, chainObj, runMock } = createMockEditor() + const { container } = render( + + ) + const btn = container.querySelector(`[data-cy="h${level}-button"]`) as HTMLButtonElement + fireEvent.click(btn) + expect(chainObj.setHeading).toHaveBeenCalledWith({ level }) + expect(runMock).toHaveBeenCalled() + }) + + it("triggers setParagraph", () => { + const { editor, chainObj, runMock } = createMockEditor() + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="paragraph-button"]') as HTMLButtonElement + fireEvent.click(btn) + expect(chainObj.setParagraph).toHaveBeenCalled() + expect(runMock).toHaveBeenCalled() + }) + + it("triggers setHorizontalRule", () => { + const { editor, chainObj, runMock } = createMockEditor() + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="horizontal-rule-button"]') as HTMLButtonElement + fireEvent.click(btn) + expect(chainObj.setHorizontalRule).toHaveBeenCalled() + expect(runMock).toHaveBeenCalled() + }) + + it("reflects active state for heading", () => { + const { editor } = createMockEditor({ + activeMarks: { + "heading:level:2": true, + }, + }) + const { container } = render( + + ) + const h2Btn = container.querySelector('[data-cy="h2-button"]') + const h1Btn = container.querySelector('[data-cy="h1-button"]') + expect(h2Btn?.getAttribute("class")).toContain("bg-secondary") + expect(h1Btn?.getAttribute("class")).not.toContain("bg-secondary") + }) + }) + + describe("Lists", () => { + it("triggers toggleBulletList", () => { + const { editor, chainObj, runMock } = createMockEditor() + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="bullet-list-button"]') as HTMLButtonElement + fireEvent.click(btn) + expect(chainObj.toggleBulletList).toHaveBeenCalled() + expect(runMock).toHaveBeenCalled() + }) + + it("triggers toggleOrderedList", () => { + const { editor, chainObj, runMock } = createMockEditor() + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="ordered-list-button"]') as HTMLButtonElement + fireEvent.click(btn) + expect(chainObj.toggleOrderedList).toHaveBeenCalled() + expect(runMock).toHaveBeenCalled() + }) + + it("triggers toggleTaskList", () => { + const { editor, chainObj, runMock } = createMockEditor() + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="task-list-button"]') as HTMLButtonElement + fireEvent.click(btn) + expect(chainObj.toggleTaskList).toHaveBeenCalled() + expect(runMock).toHaveBeenCalled() + }) + }) + + describe("Insert Link & Image", () => { + it("prompts for link URL and sets link when URL is provided", () => { + const { editor, chainObj, runMock } = createMockEditor() + jest.spyOn(browser, "prompt").mockReturnValue("https://example.com") + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="link-button"]') as HTMLButtonElement + fireEvent.click(btn) + expect(browser.prompt).toHaveBeenCalledWith("URL") + expect(chainObj.setLink).toHaveBeenCalledWith({ href: "https://example.com" }) + expect(runMock).toHaveBeenCalled() + }) + + it("does not set link when prompt returns null or empty string", () => { + const { editor, chainObj, runMock } = createMockEditor() + jest.spyOn(browser, "prompt").mockReturnValue(null) + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="link-button"]') as HTMLButtonElement + fireEvent.click(btn) + expect(browser.prompt).toHaveBeenCalledWith("URL") + expect(chainObj.setLink).not.toHaveBeenCalled() + expect(runMock).not.toHaveBeenCalled() + }) + + it("prompts for image URL and sets image when URL is provided", () => { + const { editor, chainObj, runMock } = createMockEditor() + jest.spyOn(browser, "prompt").mockReturnValue("https://example.com/test.png") + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="image-button"]') as HTMLButtonElement + fireEvent.click(btn) + expect(browser.prompt).toHaveBeenCalledWith("Image URL:") + expect(chainObj.setImage).toHaveBeenCalledWith({ src: "https://example.com/test.png" }) + expect(runMock).toHaveBeenCalled() + }) + + it("does not set image when prompt is cancelled", () => { + const { editor, chainObj, runMock } = createMockEditor() + jest.spyOn(browser, "prompt").mockReturnValue(null) + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="image-button"]') as HTMLButtonElement + fireEvent.click(btn) + expect(browser.prompt).toHaveBeenCalledWith("Image URL:") + expect(chainObj.setImage).not.toHaveBeenCalled() + expect(runMock).not.toHaveBeenCalled() + }) + }) + + describe("Alignment", () => { + it.each(["left", "center", "right"])("triggers setTextAlign %s", (alignment) => { + const { editor, chainObj, runMock } = createMockEditor() + const { container } = render( + + ) + const btn = container.querySelector(`[data-cy="align-${alignment}-button"]`) as HTMLButtonElement + fireEvent.click(btn) + expect(chainObj.setTextAlign).toHaveBeenCalledWith(alignment) + expect(runMock).toHaveBeenCalled() + }) + }) + + describe("Indent & Outdent", () => { + it("disables indent button when sinkListItem is false", () => { + const { editor } = createMockEditor({ canSink: false }) + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="indent-button"]') as HTMLButtonElement + expect(btn.disabled).toBe(true) + }) + + it("triggers sinkListItem when indent button is clicked and enabled", () => { + const { editor, chainObj, runMock } = createMockEditor({ canSink: true }) + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="indent-button"]') as HTMLButtonElement + expect(btn.disabled).toBe(false) + fireEvent.click(btn) + expect(chainObj.sinkListItem).toHaveBeenCalledWith("listItem") + expect(runMock).toHaveBeenCalled() + }) + + it("disables outdent button when liftListItem is false", () => { + const { editor } = createMockEditor({ canLift: false }) + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="outdent-button"]') as HTMLButtonElement + expect(btn.disabled).toBe(true) + }) + + it("triggers liftListItem when outdent button is clicked and enabled", () => { + const { editor, chainObj, runMock } = createMockEditor({ canLift: true }) + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="outdent-button"]') as HTMLButtonElement + expect(btn.disabled).toBe(false) + fireEvent.click(btn) + expect(chainObj.liftListItem).toHaveBeenCalledWith("listItem") + expect(runMock).toHaveBeenCalled() + }) + }) + + describe("Superscript & Subscript", () => { + it("triggers toggleSuperscript", () => { + const { editor, chainObj, runMock } = createMockEditor() + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="superscript-button"]') as HTMLButtonElement + fireEvent.click(btn) + expect(chainObj.toggleSuperscript).toHaveBeenCalled() + expect(runMock).toHaveBeenCalled() + }) + + it("triggers toggleSubscript", () => { + const { editor, chainObj, runMock } = createMockEditor() + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="subscript-button"]') as HTMLButtonElement + fireEvent.click(btn) + expect(chainObj.toggleSubscript).toHaveBeenCalled() + expect(runMock).toHaveBeenCalled() + }) + }) + + describe("Utilities", () => { + it("clears formatting on clear formatting button click", () => { + const { editor, chainObj, runMock } = createMockEditor() + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="clear-formatting-button"]') as HTMLButtonElement + fireEvent.click(btn) + expect(chainObj.unsetAllMarks).toHaveBeenCalled() + expect(chainObj.clearNodes).toHaveBeenCalled() + expect(runMock).toHaveBeenCalled() + }) + + it("disables apply markdown button when hasSelection is false", () => { + const { editor } = createMockEditor() + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="apply-markdown-button"]') as HTMLButtonElement + expect(btn.disabled).toBe(true) + }) + + it("enables apply markdown button when hasSelection is true and triggers onApplyMarkdown on click", () => { + const { editor } = createMockEditor() + const onApplyMarkdown = jest.fn() + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="apply-markdown-button"]') as HTMLButtonElement + expect(btn.disabled).toBe(false) + fireEvent.click(btn) + expect(onApplyMarkdown).toHaveBeenCalledTimes(1) + }) + + it("renders spellcheck button with active state when spellcheckEnabled is true", () => { + const { editor } = createMockEditor() + const onToggleSpellcheck = jest.fn() + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="toggle-spellcheck-button"]') as HTMLButtonElement + expect(btn.getAttribute("aria-label")).toBe("Toggle Spellcheck") + expect(btn.getAttribute("class")).toContain("bg-secondary") + fireEvent.click(btn) + expect(onToggleSpellcheck).toHaveBeenCalledTimes(1) + }) + + it("renders spellcheck button with inactive state when spellcheckEnabled is false", () => { + const { editor } = createMockEditor() + const { container } = render( + + ) + const btn = container.querySelector('[data-cy="toggle-spellcheck-button"]') as HTMLButtonElement + expect(btn.getAttribute("class")).not.toContain("bg-secondary") + }) + }) + + describe("Color picker & Select dropdowns", () => { + it("renders color picker button and opens popover on click", async () => { + const { editor } = createMockEditor({ textColor: "#ff0000" }) + const { container } = render( + + ) + const colorBtn = container.querySelector('[data-cy="color-button"]') as HTMLButtonElement + expect(colorBtn).not.toBeNull() + fireEvent.click(colorBtn) + // Popover should render TwitterPicker content + await waitFor(() => { + expect(document.querySelector(".twitter-picker")).not.toBeNull() + }) + }) + + it("renders font family and font size select triggers", () => { + const { editor } = createMockEditor() + const { container } = render( + + ) + const fontFamilyBtn = container.querySelector('[data-cy="font-family-button"]') + const fontSizeBtn = container.querySelector('[data-cy="font-size-button"]') + expect(fontFamilyBtn).not.toBeNull() + expect(fontSizeBtn).not.toBeNull() + }) + }) +}) diff --git a/ui/web/tests/unit/components/ErrorBoundary.test.tsx b/ui/web/tests/unit/components/ErrorBoundary.test.tsx new file mode 100644 index 00000000000..de6b11b50a0 --- /dev/null +++ b/ui/web/tests/unit/components/ErrorBoundary.test.tsx @@ -0,0 +1,126 @@ +import React from "react" +import { fireEvent, render, screen } from "@testing-library/react" + +import { ErrorBoundary } from "@/components/ErrorBoundary" +import { browser } from "@ui/web/adapters/browser" + +const ProblematicComponent = ({ shouldThrow }: { shouldThrow: boolean }) => { + if (shouldThrow) { + throw new Error("Test component crashed!") + } + return
Everything is fine
+} + +describe("ErrorBoundary", () => { + let consoleErrorSpy: jest.SpyInstance + + beforeEach(() => { + jest.clearAllMocks() + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}) + }) + + afterEach(() => { + consoleErrorSpy.mockRestore() + }) + + it("renders children normally when no rendering error occurs", () => { + render( + + + + ) + + expect(screen.getByText("Everything is fine")).toBeTruthy() + expect(screen.queryByText("Something went wrong")).toBeNull() + }) + + it("catches rendering errors, logs to console.error, and displays fallback UI", () => { + const originalEnv = process.env.NODE_ENV + process.env.NODE_ENV = "development" + + try { + render( + + + + ) + + expect(screen.queryByText("Everything is fine")).toBeNull() + expect(screen.getByText("Something went wrong")).toBeTruthy() + expect( + screen.getByText( + "The application encountered an unexpected error. Please try refreshing the page." + ) + ).toBeTruthy() + + // Verifies development environment error info rendering + expect(screen.getByText(/Error: Test component crashed!/i)).toBeTruthy() + expect(screen.getByText("Stack trace")).toBeTruthy() + + // Verifies console.error logging by componentDidCatch + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error caught by boundary:", + expect.any(Error), + expect.objectContaining({ componentStack: expect.any(String) }) + ) + } finally { + process.env.NODE_ENV = originalEnv + } + }) + + it("reloads browser location when Reload Application button is clicked", () => { + const reloadMock = jest.fn() + jest.spyOn(browser, "location", "get").mockReturnValue({ + origin: "", + search: "", + reload: reloadMock, + }) + + render( + + + + ) + + const reloadButton = screen.getByRole("button", { name: "Reload Application" }) + fireEvent.click(reloadButton) + + expect(reloadMock).toHaveBeenCalledTimes(1) + }) + + it("navigates back when Go Back button is clicked and history length is greater than 0", () => { + const backSpy = jest.spyOn(window.history, "back").mockImplementation(() => {}) + + render( + + + + ) + + const goBackButton = screen.getByRole("button", { name: "Go Back" }) + fireEvent.click(goBackButton) + + expect(backSpy).toHaveBeenCalledTimes(1) + }) + + it("reloads browser location when Go Back button is clicked and history length is 0", () => { + const reloadMock = jest.fn() + jest.spyOn(browser, "location", "get").mockReturnValue({ + origin: "", + search: "", + reload: reloadMock, + }) + jest.spyOn(window.history, "length", "get").mockReturnValue(0) + + render( + + + + ) + + const goBackButton = screen.getByRole("button", { name: "Go Back" }) + fireEvent.click(goBackButton) + + expect(reloadMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/ui/web/tests/unit/components/ExportProgressDialog.test.tsx b/ui/web/tests/unit/components/ExportProgressDialog.test.tsx new file mode 100644 index 00000000000..2167b2bd2c7 --- /dev/null +++ b/ui/web/tests/unit/components/ExportProgressDialog.test.tsx @@ -0,0 +1,92 @@ +import React from "react" +import { fireEvent, render, screen } from "@testing-library/react" + +import { ExportProgressDialog } from "@/components/ExportProgressDialog" +import type { ExportProgress } from "@core/enex/export-types" + +describe("ExportProgressDialog", () => { + const defaultProgress: ExportProgress = { + currentNote: 25, + totalNotes: 100, + currentStep: "fetching", + message: "Processing notes...", + } + + const defaultProps = { + open: true, + progress: defaultProgress, + onClose: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("does not render dialog content when open is false", () => { + render() + + expect(screen.queryByText("Export in progress")).toBeNull() + }) + + it("renders in-progress state with calculated percentage and note counters", () => { + render() + + expect(screen.getByText("Export in progress")).toBeTruthy() + expect(screen.getByText("Please keep this window open until export finishes.")).toBeTruthy() + expect(screen.getByText("25 of 100")).toBeTruthy() + expect(screen.getByText("25%")).toBeTruthy() + const footerBtn = screen.queryAllByRole("button", { name: "Close" }).find((btn) => btn.className.includes("rounded-full")) + expect(footerBtn).toBeUndefined() + }) + + it("calculates 0% when totalNotes is 0", () => { + const zeroProgress: ExportProgress = { + currentNote: 0, + totalNotes: 0, + currentStep: "fetching", + message: "Preparing...", + } + + render() + + expect(screen.getByText("0 of 0")).toBeTruthy() + expect(screen.getByText("0%")).toBeTruthy() + }) + + it("renders completed state with Close button and triggers onClose on click", () => { + const onCloseMock = jest.fn() + const completeProgress: ExportProgress = { + currentNote: 100, + totalNotes: 100, + currentStep: "complete", + message: "Export finished successfully", + } + + render() + + expect(screen.getByText("Export completed")).toBeTruthy() + expect(screen.getByText("File is ready to download.")).toBeTruthy() + expect(screen.getByText("100%")).toBeTruthy() + + const closeButton = screen.getAllByRole("button", { name: "Close" })[0] + expect(closeButton).toBeTruthy() + + fireEvent.click(closeButton) + expect(onCloseMock).toHaveBeenCalledTimes(1) + }) + + it("renders export completed with errors when message contains error details", () => { + const errorProgress: ExportProgress = { + currentNote: 80, + totalNotes: 100, + currentStep: "complete", + message: "Export completed with 2 item errors", + } + + render() + + expect(screen.getByText("Export completed with errors")).toBeTruthy() + expect(screen.getByText("File is ready to download.")).toBeTruthy() + expect(screen.getAllByRole("button", { name: "Close" }).length).toBeGreaterThan(0) + }) +}) diff --git a/ui/web/tests/unit/components/ImportProgressDialog.test.tsx b/ui/web/tests/unit/components/ImportProgressDialog.test.tsx new file mode 100644 index 00000000000..6585fb0ba14 --- /dev/null +++ b/ui/web/tests/unit/components/ImportProgressDialog.test.tsx @@ -0,0 +1,185 @@ +import * as React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import { ImportProgressDialog } from "@ui/web/components/ImportProgressDialog" +import type { ImportProgress, ImportResult } from "@core/enex/types" + +describe("ImportProgressDialog", () => { + const defaultProgress: ImportProgress = { + currentFile: 1, + totalFiles: 1, + currentNote: 5, + totalNotes: 10, + fileName: "export.enex", + } + + const defaultProps = { + open: true, + progress: defaultProgress, + result: null, + onClose: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("Modal Open & Closed States", () => { + it("renders nothing when open prop is false", () => { + render() + expect(screen.queryByText("Importing ENEX file")).toBeNull() + }) + + it("renders in-progress dialog when open is true and result is null", () => { + render() + + const titleHeading = screen.getByRole("heading", { level: 2 }) + expect(titleHeading.textContent).toContain("Importing ENEX file") + expect(screen.getByText("Please wait while we import your notes...")).toBeTruthy() + }) + }) + + describe("Progress calculations & File display", () => { + it("calculates note progress percentage correctly", () => { + render( + + ) + + expect(screen.getByText("7 of 10")).toBeTruthy() + expect(screen.getByText("70%")).toBeTruthy() + expect(screen.getByText("archive.enex")).toBeTruthy() + }) + + it("renders file progress when totalFiles is greater than 1", () => { + render( + + ) + + expect(screen.getByText("2 of 5")).toBeTruthy() + expect(screen.getByText("Files")).toBeTruthy() + }) + + it("hides file progress section when totalFiles is 1 or less", () => { + render( + + ) + + expect(screen.queryByText("Files")).toBeNull() + }) + + it("handles zero totalNotes without crashing or dividing by zero", () => { + render( + + ) + + expect(screen.getByText("0%")).toBeTruthy() + expect(screen.getByText("0 of 0")).toBeTruthy() + }) + }) + + describe("Import Complete state & Error messages", () => { + it("renders successful import summary with checkmark icon", () => { + const result: ImportResult = { + success: 12, + errors: 0, + failedNotes: [], + message: "Successfully imported 12 notes.", + } + + render() + + const titleHeading = screen.getByRole("heading", { level: 2 }) + expect(titleHeading.textContent).toContain("Import Complete") + expect(screen.getByText("Your import has finished.")).toBeTruthy() + expect(screen.getByText("12")).toBeTruthy() + expect(screen.getByText("Successful")).toBeTruthy() + expect(screen.getByText("Successfully imported 12 notes.")).toBeTruthy() + }) + + it("renders failure icon when success is 0 and errors > 0", () => { + const result: ImportResult = { + success: 0, + errors: 3, + failedNotes: [ + { title: "Bad Note 1", error: "Invalid XML" }, + { title: "Bad Note 2", error: "Unsupported attachment" }, + ], + message: "Import failed for all notes.", + } + + render() + + expect(screen.getByText("0")).toBeTruthy() + expect(screen.getByText("3")).toBeTruthy() + expect(screen.getByText("Failed")).toBeTruthy() + expect(screen.getByText("View failed notes (2)")).toBeTruthy() + expect(screen.getByText("Bad Note 1")).toBeTruthy() + expect(screen.getByText("Invalid XML")).toBeTruthy() + }) + }) + + describe("Close Button behavior", () => { + it("renders Close button when import is complete and calls onClose on click", () => { + const onClose = jest.fn() + const result: ImportResult = { + success: 5, + errors: 0, + failedNotes: [], + message: "Done", + } + + render() + + const closeButtons = screen.getAllByRole("button", { name: "Close" }) + const footerBtn = closeButtons.find(btn => !btn.querySelector("svg"))! + expect(footerBtn).toBeTruthy() + + fireEvent.click(footerBtn) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it("does not render footer Close button during in-progress import", () => { + render() + + const closeButtons = screen.getAllByRole("button", { name: "Close" }) + const footerBtn = closeButtons.find(btn => !btn.querySelector("svg")) + expect(footerBtn).toBeUndefined() + }) + }) +}) diff --git a/ui/web/tests/unit/components/VirtualNoteList.test.tsx b/ui/web/tests/unit/components/VirtualNoteList.test.tsx new file mode 100644 index 00000000000..8af3c6f08c4 --- /dev/null +++ b/ui/web/tests/unit/components/VirtualNoteList.test.tsx @@ -0,0 +1,303 @@ +import * as React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import { VirtualNoteList } from "@/components/VirtualNoteList" +import type { Note } from "@core/types/domain" + +jest.mock("react-window", () => ({ + List: ({ children, itemCount, itemData }: { children: React.ComponentType; itemCount: number; itemData: unknown }) => { + const Row = children as unknown as React.ComponentType<{ index: number; style: React.CSSProperties; data: unknown }> + return ( +
+ {Array.from({ length: itemCount }).map((_, index) => ( + + ))} +
+ ) + }, +})) + +function makeNote(overrides: Partial = {}): Note { + return { + id: "note-1", + user_id: "user-1", + title: "Sample Note Title", + content: "Sample note content", + description: "Sample note description", + is_pinned: false, + is_archived: false, + is_trashed: false, + created_at: "2026-01-01T10:00:00.000Z", + updated_at: "2026-01-02T12:00:00.000Z", + tags: ["frontend", "react", "testing", "extra-tag"], + ...overrides, + } as Note +} + +describe("VirtualNoteList", () => { + const defaultProps = { + height: 600, + selectedNote: null, + onSelectNote: jest.fn(), + onTagClick: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("Empty & Null states", () => { + it("renders null when notes array is empty", () => { + const { container } = render( + + ) + expect(container.firstChild).toBeNull() + }) + + it("renders null when notes prop is null or undefined", () => { + const { container } = render( + // @ts-expect-error testing runtime fallback + + ) + expect(container.firstChild).toBeNull() + }) + }) + + describe("Virtualized rendering & Note items", () => { + it("renders notes using the virtual list component", () => { + const notes = [ + makeNote({ id: "note-1", title: "Note 1" }), + makeNote({ id: "note-2", title: "Note 2" }), + ] + + render() + + expect(screen.getByText("Note 1")).toBeTruthy() + expect(screen.getByText("Note 2")).toBeTruthy() + }) + + it("renders skeleton placeholder when note is missing in itemData index", () => { + const CustomList = ({ children, itemData }: { children: React.ComponentType; itemData: Record }) => { + const Row = children as unknown as React.ComponentType<{ index: number; style: React.CSSProperties; data: unknown }> + return ( +
+ +
+ ) + } + + const { container } = render( + + ) + + expect(container.querySelector(".animate-pulse")).toBeTruthy() + }) + }) + + describe("Note text & tag fallbacks", () => { + it("renders default title and description fallbacks when fields are empty", () => { + const notes = [ + makeNote({ + id: "empty-note", + title: "", + // @ts-expect-error testing null description + description: null, + tags: [], + }), + ] + + render() + + expect(screen.getByText("Untitled Note")).toBeTruthy() + expect(screen.getByText("No additional text")).toBeTruthy() + }) + + it("slices tags to a maximum of 3 items", () => { + const notes = [ + makeNote({ + tags: ["tag1", "tag2", "tag3", "tag4", "tag5"], + }), + ] + + render() + + expect(screen.getByText("tag1")).toBeTruthy() + expect(screen.getByText("tag2")).toBeTruthy() + expect(screen.getByText("tag3")).toBeTruthy() + expect(screen.queryByText("tag4")).toBeNull() + expect(screen.queryByText("tag5")).toBeNull() + }) + + it("renders spacer when note has no tags", () => { + const notes = [makeNote({ tags: [] })] + const { container } = render( + + ) + expect(container.querySelector("span.h-5")).toBeTruthy() + }) + }) + + describe("Selection State", () => { + it("renders selected styling when note matches selectedNote id", () => { + const note1 = makeNote({ id: "note-1", title: "Note 1" }) + const note2 = makeNote({ id: "note-2", title: "Note 2" }) + const notes = [note1, note2] + + render( + + ) + + const note1Button = screen.getByRole("button", { name: /Note 1/ }) + const note2Button = screen.getByRole("button", { name: /Note 2/ }) + + expect(note1Button.getAttribute("aria-pressed")).toBe("true") + expect(note1Button.className).toContain("bg-accent") + + expect(note2Button.getAttribute("aria-pressed")).toBe("false") + expect(note2Button.className).toContain("bg-card") + }) + }) + + describe("Click and Keyboard Callbacks", () => { + it("calls onSelectNote when a note item is clicked", () => { + const note = makeNote({ id: "note-1", title: "Clickable Note" }) + const onSelectNote = jest.fn() + + render( + + ) + + const noteBtn = screen.getByRole("button", { name: /Clickable Note/ }) + fireEvent.click(noteBtn) + + expect(onSelectNote).toHaveBeenCalledTimes(1) + expect(onSelectNote).toHaveBeenCalledWith(note) + }) + + it("calls onSelectNote when Enter or Space key is pressed on note item", () => { + const note = makeNote({ id: "note-1", title: "Keyboard Note" }) + const onSelectNote = jest.fn() + + render( + + ) + + const noteBtn = screen.getByRole("button", { name: /Keyboard Note/ }) + + fireEvent.keyDown(noteBtn, { key: "Enter" }) + expect(onSelectNote).toHaveBeenCalledTimes(1) + expect(onSelectNote).toHaveBeenCalledWith(note) + + fireEvent.keyDown(noteBtn, { key: " " }) + expect(onSelectNote).toHaveBeenCalledTimes(2) + + // Unhandled keys should not trigger selection + fireEvent.keyDown(noteBtn, { key: "Tab" }) + expect(onSelectNote).toHaveBeenCalledTimes(2) + }) + + it("does not call onSelectNote if keydown event originates from child element", () => { + const note = makeNote({ id: "note-1", title: "Parent Note" }) + const onSelectNote = jest.fn() + + render( + + ) + + const titleHeader = screen.getByText("Parent Note") + fireEvent.keyDown(titleHeader, { key: "Enter", bubbles: true }) + + expect(onSelectNote).not.toHaveBeenCalled() + }) + + it("calls onTagClick when a tag is clicked", () => { + const note = makeNote({ id: "note-1", tags: ["react"] }) + const onTagClick = jest.fn() + const onSelectNote = jest.fn() + + render( + + ) + + const tagElement = screen.getByText("react") + fireEvent.click(tagElement) + + expect(onTagClick).toHaveBeenCalledWith("react") + expect(onSelectNote).not.toHaveBeenCalled() + }) + }) + + describe("Custom ListComponent & Window Resizing", () => { + it("passes custom height and virtualizer parameters to ListComponent", () => { + const customListSpy = jest.fn(({ children, height }: { children: React.ComponentType; height: number }) => { + const Row = children as unknown as React.ComponentType<{ index: number; style: React.CSSProperties; data: unknown }> + return ( +
+ +
+ ) + }) + + const { rerender } = render( + + ) + + expect(customListSpy).toHaveBeenCalledWith( + expect.objectContaining({ + height: 400, + itemCount: 1, + itemSize: 120, + width: "100%", + overscanCount: 5, + }), + undefined + ) + + // Simulate window resize changing height prop to 800 + rerender( + + ) + + expect(customListSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + height: 800, + }), + undefined + ) + }) + }) +}) diff --git a/ui/web/tests/unit/components/executeEditorCommand.test.ts b/ui/web/tests/unit/components/executeEditorCommand.test.ts new file mode 100644 index 00000000000..4a04c9af1f8 --- /dev/null +++ b/ui/web/tests/unit/components/executeEditorCommand.test.ts @@ -0,0 +1,455 @@ +import type { Editor } from "@tiptap/react" +import { executeEditorCommand } from "@ui/web/components/executeEditorCommand" + +type MockChain = Record & { + focus: jest.Mock + unsetAllMarks: jest.Mock + clearNodes: jest.Mock + extendMarkRange: jest.Mock + setLink: jest.Mock + unsetLink: jest.Mock + setImage: jest.Mock + toggleHeading: jest.Mock + run: jest.Mock +} + +function createMockEditor() { + const chain: MockChain = { + focus: jest.fn(), + unsetAllMarks: jest.fn(), + clearNodes: jest.fn(), + extendMarkRange: jest.fn(), + setLink: jest.fn(), + unsetLink: jest.fn(), + setImage: jest.fn(), + toggleHeading: jest.fn(), + run: jest.fn(), + } + + chain.focus.mockReturnValue(chain) + chain.unsetAllMarks.mockReturnValue(chain) + chain.clearNodes.mockReturnValue(chain) + chain.extendMarkRange.mockReturnValue(chain) + chain.setLink.mockReturnValue(chain) + chain.unsetLink.mockReturnValue(chain) + chain.setImage.mockReturnValue(chain) + chain.toggleHeading.mockReturnValue(chain) + + const undoMock = jest.fn() + const redoMock = jest.fn() + + const editor = { + commands: { + undo: undoMock, + redo: redoMock, + }, + chain: jest.fn().mockReturnValue(chain), + } + + return { + editor: editor as unknown as Editor, + chain, + undoMock, + redoMock, + } +} + +describe("executeEditorCommand", () => { + let onApplySelectionAsMarkdown: jest.Mock + + beforeEach(() => { + jest.clearAllMocks() + onApplySelectionAsMarkdown = jest.fn() + }) + + describe("undo & redo commands", () => { + it("executes undo command via editor.commands.undo()", () => { + const { editor, undoMock, chain } = createMockEditor() + + executeEditorCommand({ + editor, + command: "undo", + args: [], + onApplySelectionAsMarkdown, + }) + + expect(undoMock).toHaveBeenCalledTimes(1) + expect(onApplySelectionAsMarkdown).not.toHaveBeenCalled() + expect(editor.chain).not.toHaveBeenCalled() + expect(chain.run).not.toHaveBeenCalled() + }) + + it("executes redo command via editor.commands.redo()", () => { + const { editor, redoMock, chain } = createMockEditor() + + executeEditorCommand({ + editor, + command: "redo", + args: [], + onApplySelectionAsMarkdown, + }) + + expect(redoMock).toHaveBeenCalledTimes(1) + expect(onApplySelectionAsMarkdown).not.toHaveBeenCalled() + expect(editor.chain).not.toHaveBeenCalled() + expect(chain.run).not.toHaveBeenCalled() + }) + }) + + describe("applySelectionAsMarkdown command", () => { + it("calls onApplySelectionAsMarkdown callback and does not touch editor chain or commands", () => { + const { editor, undoMock, redoMock, chain } = createMockEditor() + + executeEditorCommand({ + editor, + command: "applySelectionAsMarkdown", + args: [], + onApplySelectionAsMarkdown, + }) + + expect(onApplySelectionAsMarkdown).toHaveBeenCalledTimes(1) + expect(undoMock).not.toHaveBeenCalled() + expect(redoMock).not.toHaveBeenCalled() + expect(editor.chain).not.toHaveBeenCalled() + expect(chain.run).not.toHaveBeenCalled() + }) + }) + + describe("clearFormatting command", () => { + it("chains focus -> unsetAllMarks -> clearNodes -> run", () => { + const { editor, chain } = createMockEditor() + + executeEditorCommand({ + editor, + command: "clearFormatting", + args: [], + onApplySelectionAsMarkdown, + }) + + expect(editor.chain).toHaveBeenCalledTimes(1) + expect(chain.focus).toHaveBeenCalledTimes(1) + expect(chain.unsetAllMarks).toHaveBeenCalledTimes(1) + expect(chain.clearNodes).toHaveBeenCalledTimes(1) + expect(chain.run).toHaveBeenCalledTimes(1) + expect(onApplySelectionAsMarkdown).not.toHaveBeenCalled() + }) + }) + + describe("setLinkUrl command", () => { + it("sets link URL when a non-empty string argument is passed (trimming whitespace)", () => { + const { editor, chain } = createMockEditor() + + executeEditorCommand({ + editor, + command: "setLinkUrl", + args: [" https://example.com/test "], + onApplySelectionAsMarkdown, + }) + + expect(editor.chain).toHaveBeenCalledTimes(1) + expect(chain.focus).toHaveBeenCalledTimes(1) + expect(chain.extendMarkRange).toHaveBeenCalledWith("link") + expect(chain.setLink).toHaveBeenCalledWith({ href: "https://example.com/test" }) + expect(chain.unsetLink).not.toHaveBeenCalled() + expect(chain.run).toHaveBeenCalledTimes(1) + }) + + it("unsets link when an empty string argument is passed", () => { + const { editor, chain } = createMockEditor() + + executeEditorCommand({ + editor, + command: "setLinkUrl", + args: [" "], + onApplySelectionAsMarkdown, + }) + + expect(editor.chain).toHaveBeenCalledTimes(1) + expect(chain.focus).toHaveBeenCalledTimes(1) + expect(chain.extendMarkRange).toHaveBeenCalledWith("link") + expect(chain.unsetLink).toHaveBeenCalledTimes(1) + expect(chain.setLink).not.toHaveBeenCalled() + expect(chain.run).toHaveBeenCalledTimes(1) + }) + + it("ignores command when non-string argument is provided", () => { + const { editor, chain } = createMockEditor() + + executeEditorCommand({ + editor, + command: "setLinkUrl", + args: [12345], + onApplySelectionAsMarkdown, + }) + + expect(editor.chain).not.toHaveBeenCalled() + expect(chain.run).not.toHaveBeenCalled() + }) + + it("ignores command when argument list is empty", () => { + const { editor, chain } = createMockEditor() + + executeEditorCommand({ + editor, + command: "setLinkUrl", + args: [], + onApplySelectionAsMarkdown, + }) + + expect(editor.chain).not.toHaveBeenCalled() + expect(chain.run).not.toHaveBeenCalled() + }) + }) + + describe("insertImageUrl command", () => { + it("inserts image when a valid non-empty string URL argument is passed (trimming whitespace)", () => { + const { editor, chain } = createMockEditor() + + executeEditorCommand({ + editor, + command: "insertImageUrl", + args: [" https://example.com/image.png "], + onApplySelectionAsMarkdown, + }) + + expect(editor.chain).toHaveBeenCalledTimes(1) + expect(chain.focus).toHaveBeenCalledTimes(1) + expect(chain.setImage).toHaveBeenCalledWith({ src: "https://example.com/image.png" }) + expect(chain.run).toHaveBeenCalledTimes(1) + }) + + it("does nothing when an empty string URL is passed", () => { + const { editor, chain } = createMockEditor() + + executeEditorCommand({ + editor, + command: "insertImageUrl", + args: [" "], + onApplySelectionAsMarkdown, + }) + + expect(editor.chain).not.toHaveBeenCalled() + expect(chain.setImage).not.toHaveBeenCalled() + expect(chain.run).not.toHaveBeenCalled() + }) + + it("does nothing when first arg is not a string", () => { + const { editor, chain } = createMockEditor() + + executeEditorCommand({ + editor, + command: "insertImageUrl", + args: [null], + onApplySelectionAsMarkdown, + }) + + expect(editor.chain).not.toHaveBeenCalled() + expect(chain.setImage).not.toHaveBeenCalled() + expect(chain.run).not.toHaveBeenCalled() + }) + + it("does nothing when args is empty", () => { + const { editor, chain } = createMockEditor() + + executeEditorCommand({ + editor, + command: "insertImageUrl", + args: [], + onApplySelectionAsMarkdown, + }) + + expect(editor.chain).not.toHaveBeenCalled() + expect(chain.setImage).not.toHaveBeenCalled() + expect(chain.run).not.toHaveBeenCalled() + }) + }) + + describe("toggleHeadingLevel command", () => { + it.each([1, 2, 3])("toggles heading level %i when passed as a number", (level) => { + const { editor, chain } = createMockEditor() + + executeEditorCommand({ + editor, + command: "toggleHeadingLevel", + args: [level], + onApplySelectionAsMarkdown, + }) + + expect(editor.chain).toHaveBeenCalledTimes(1) + expect(chain.focus).toHaveBeenCalledTimes(1) + expect(chain.toggleHeading).toHaveBeenCalledWith({ level }) + expect(chain.run).toHaveBeenCalledTimes(1) + }) + + it("toggles heading level when passed as a numeric string", () => { + const { editor, chain } = createMockEditor() + + executeEditorCommand({ + editor, + command: "toggleHeadingLevel", + args: ["2"], + onApplySelectionAsMarkdown, + }) + + expect(editor.chain).toHaveBeenCalledTimes(1) + expect(chain.focus).toHaveBeenCalledTimes(1) + expect(chain.toggleHeading).toHaveBeenCalledWith({ level: 2 }) + expect(chain.run).toHaveBeenCalledTimes(1) + }) + + it.each([0, 4, 5, -1])("ignores invalid heading level %i", (invalidLevel) => { + const { editor, chain } = createMockEditor() + + executeEditorCommand({ + editor, + command: "toggleHeadingLevel", + args: [invalidLevel], + onApplySelectionAsMarkdown, + }) + + expect(editor.chain).not.toHaveBeenCalled() + expect(chain.toggleHeading).not.toHaveBeenCalled() + expect(chain.run).not.toHaveBeenCalled() + }) + + it("ignores non-numeric or empty arguments for heading level", () => { + const { editor, chain } = createMockEditor() + + executeEditorCommand({ + editor, + command: "toggleHeadingLevel", + args: ["invalid"], + onApplySelectionAsMarkdown, + }) + + expect(editor.chain).not.toHaveBeenCalled() + expect(chain.toggleHeading).not.toHaveBeenCalled() + expect(chain.run).not.toHaveBeenCalled() + }) + }) + + describe("dynamic editor commands (formatting, lists, alignment, custom commands)", () => { + it.each([ + "toggleBold", + "toggleItalic", + "toggleStrike", + "toggleCode", + "toggleBlockquote", + "toggleBulletList", + "toggleOrderedList", + "toggleTaskList", + ])("executes dynamic command '%s' with no arguments", (cmdName) => { + const { editor, chain } = createMockEditor() + const commandMock = jest.fn().mockReturnValue(chain) + chain[cmdName] = commandMock + + executeEditorCommand({ + editor, + command: cmdName, + args: [], + onApplySelectionAsMarkdown, + }) + + expect(commandMock).toHaveBeenCalledTimes(1) + expect(commandMock).toHaveBeenCalledWith() + expect(chain.run).toHaveBeenCalledTimes(1) + }) + + it("executes dynamic command with arguments (e.g. setTextAlign)", () => { + const { editor, chain } = createMockEditor() + const setTextAlignMock = jest.fn().mockReturnValue(chain) + chain.setTextAlign = setTextAlignMock + + executeEditorCommand({ + editor, + command: "setTextAlign", + args: ["center"], + onApplySelectionAsMarkdown, + }) + + expect(setTextAlignMock).toHaveBeenCalledTimes(1) + expect(setTextAlignMock).toHaveBeenCalledWith("center") + expect(chain.run).toHaveBeenCalledTimes(1) + }) + + it("does nothing if command is not a property on the editor chain", () => { + const { editor, chain } = createMockEditor() + + executeEditorCommand({ + editor, + command: "nonExistentCommand", + args: [], + onApplySelectionAsMarkdown, + }) + + expect(editor.chain).toHaveBeenCalledTimes(1) + expect(chain.run).not.toHaveBeenCalled() + }) + + it("does nothing if property on chain exists but is not a function", () => { + const { editor, chain } = createMockEditor() + chain.someStaticProperty = "not a function" + + executeEditorCommand({ + editor, + command: "someStaticProperty", + args: [], + onApplySelectionAsMarkdown, + }) + + expect(editor.chain).toHaveBeenCalledTimes(1) + expect(chain.run).not.toHaveBeenCalled() + }) + + it("handles command function returning null without throwing", () => { + const { editor, chain } = createMockEditor() + chain.customCommand = jest.fn().mockReturnValue(null) + + expect(() => { + executeEditorCommand({ + editor, + command: "customCommand", + args: [1, 2], + onApplySelectionAsMarkdown, + }) + }).not.toThrow() + + expect(chain.customCommand).toHaveBeenCalledWith(1, 2) + expect(chain.run).not.toHaveBeenCalled() + }) + + it("handles command function returning an object without a run method without throwing", () => { + const { editor, chain } = createMockEditor() + chain.customCommand = jest.fn().mockReturnValue({ result: "ok" }) + + expect(() => { + executeEditorCommand({ + editor, + command: "customCommand", + args: [], + onApplySelectionAsMarkdown, + }) + }).not.toThrow() + + expect(chain.customCommand).toHaveBeenCalledTimes(1) + expect(chain.run).not.toHaveBeenCalled() + }) + + it("handles command function returning an object with a non-function run property", () => { + const { editor, chain } = createMockEditor() + chain.customCommand = jest.fn().mockReturnValue({ run: "invalid" }) + + expect(() => { + executeEditorCommand({ + editor, + command: "customCommand", + args: [], + onApplySelectionAsMarkdown, + }) + }).not.toThrow() + + expect(chain.customCommand).toHaveBeenCalledTimes(1) + expect(chain.run).not.toHaveBeenCalled() + }) + }) +}) diff --git a/ui/web/tests/unit/components/features/account/DeleteAccountDialog.test.tsx b/ui/web/tests/unit/components/features/account/DeleteAccountDialog.test.tsx new file mode 100644 index 00000000000..06ae33e3015 --- /dev/null +++ b/ui/web/tests/unit/components/features/account/DeleteAccountDialog.test.tsx @@ -0,0 +1,106 @@ +import React from "react" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" + +import { DeleteAccountDialog } from "@/components/features/account/DeleteAccountDialog" + +describe("DeleteAccountDialog", () => { + const defaultProps = { + open: true, + onOpenChange: jest.fn(), + onConfirm: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("does not render dialog content when open is false", () => { + render() + + expect(screen.queryByText("Delete my account")).toBeNull() + }) + + it("renders dialog title, description, tip message, checkbox label, and buttons when open is true", () => { + render() + + expect(screen.getByRole("heading", { name: "Delete my account" })).toBeTruthy() + expect( + screen.getByText( + "This will permanently delete your account and all notes. Please export your notes before deleting if you need a copy." + ) + ).toBeTruthy() + expect( + screen.getByText( + "Tip: use the Export option in the settings menu to download your notes before deleting your account." + ) + ).toBeTruthy() + expect( + screen.getByText("I understand that my account and all notes will be permanently deleted.") + ).toBeTruthy() + expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy() + expect(screen.getByRole("button", { name: "Delete account" })).toBeTruthy() + }) + + it("disables delete account button until acknowledgment checkbox is checked", () => { + render() + + const deleteButton = screen.getByRole("button", { name: "Delete account" }) as HTMLButtonElement + const checkbox = screen.getByRole("checkbox", { + name: "I understand that my account and all notes will be permanently deleted.", + }) + + expect(deleteButton.disabled).toBe(true) + + fireEvent.click(checkbox) + expect(deleteButton.disabled).toBe(false) + + fireEvent.click(checkbox) + expect(deleteButton.disabled).toBe(true) + }) + + it("handles cancel button click and calls onOpenChange(false)", () => { + const onOpenChangeMock = jest.fn() + + render() + + const cancelButton = screen.getByRole("button", { name: "Cancel" }) + fireEvent.click(cancelButton) + + expect(onOpenChangeMock).toHaveBeenCalledWith(false) + }) + + it("executes onConfirm when enabled delete account button is clicked", async () => { + let resolveConfirm!: () => void + const confirmPromise = new Promise((resolve) => { + resolveConfirm = resolve + }) + const onConfirmMock = jest.fn().mockReturnValue(confirmPromise) + + render() + + const checkbox = screen.getByRole("checkbox") + fireEvent.click(checkbox) + + const deleteButton = screen.getByRole("button", { name: "Delete account" }) + fireEvent.click(deleteButton) + + expect(onConfirmMock).toHaveBeenCalledTimes(1) + + resolveConfirm() + + await waitFor(() => { + const buttonAfter = screen.getByRole("button", { name: "Delete account" }) as HTMLButtonElement + expect(buttonAfter.disabled).toBe(true) + }) + }) + + it("disables cancel and action buttons and updates action text when loading is true", () => { + render() + + const cancelButton = screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement + const deletingButton = screen.getByRole("button", { name: "Deleting..." }) as HTMLButtonElement + + expect(cancelButton.disabled).toBe(true) + expect(deletingButton.disabled).toBe(true) + }) +}) diff --git a/ui/web/tests/unit/components/features/notes/MoreActionsMenu.test.tsx b/ui/web/tests/unit/components/features/notes/MoreActionsMenu.test.tsx new file mode 100644 index 00000000000..9a34f7d4e3e --- /dev/null +++ b/ui/web/tests/unit/components/features/notes/MoreActionsMenu.test.tsx @@ -0,0 +1,150 @@ +import * as React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import { MoreActionsMenu } from "@/components/features/notes/MoreActionsMenu" +import type { ExportableWordPressNote } from "@/components/features/wordpress/ExportToWordPressButton" + +jest.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children, open }: { children: React.ReactNode; open?: boolean }) => ( +
+ {children} +
+ ), + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children, onSelect, onClick, ...props }: { children?: React.ReactNode; onSelect?: (e: { preventDefault: () => void }) => void; onClick?: (e: React.MouseEvent) => void; className?: string }) => ( + + ), + DropdownMenuSeparator: () =>
, +})) + +jest.mock("@/components/features/notes/ShareNoteDialog", () => ({ + ShareNoteDialog: ({ noteId, open }: { noteId: string; open: boolean }) => ( + open ?
Share Note Dialog
: null + ), +})) + +jest.mock("@/components/features/notes/RagIndexPanel", () => ({ + RagIndexPanel: ({ noteId, variant, onMenuClose }: { noteId: string; variant: string; onMenuClose?: () => void }) => ( +
+ +
+ ), +})) + +jest.mock("@/components/features/wordpress/ExportToWordPressButton", () => ({ + ExportToWordPressButton: ({ onRequestExport, getNote }: { onRequestExport: (note: ExportableWordPressNote) => void; getNote: () => ExportableWordPressNote | null }) => ( + + ), +})) + +jest.mock("@/components/features/wordpress/WordPressExportDialog", () => ({ + WordPressExportDialog: ({ open, note }: { open: boolean; note: ExportableWordPressNote }) => ( + open ?
{note.title}
: null + ), +})) + +describe("MoreActionsMenu", () => { + const sampleExportNote: ExportableWordPressNote = { + id: "note-1", + title: "Test Note", + description: "

Test Content

", + tags: ["tech"], + } + + const defaultProps = { + noteId: "note-1", + wordpressConfigured: false, + getExportNote: () => sampleExportNote, + } + + afterEach(() => { + jest.clearAllMocks() + }) + + it("renders trigger button and default menu items", () => { + render() + + const triggerBtn = screen.getByRole("button", { name: "More actions" }) + expect(triggerBtn).toBeTruthy() + + expect(screen.getByText("Share note")).toBeTruthy() + expect(screen.getByTestId("mock-rag-index-panel")).toBeTruthy() + expect(screen.queryByTestId("wordpress-export-trigger")).toBeNull() + expect(screen.queryByText("Delete note")).toBeNull() + }) + + it("opens ShareNoteDialog when Share note item is selected", () => { + render() + + const shareItem = screen.getByText("Share note") + fireEvent.click(shareItem) + + expect(screen.getByTestId("share-note-dialog")).toBeTruthy() + expect(screen.getByTestId("share-note-dialog").getAttribute("data-note-id")).toBe("note-1") + }) + + it("renders WordPress export item when wordpressConfigured is true and opens export dialog on click", () => { + render( + + ) + + const wpExportBtn = screen.getByTestId("wordpress-export-trigger") + expect(wpExportBtn).toBeTruthy() + + fireEvent.click(wpExportBtn) + + expect(screen.getByTestId("wordpress-export-dialog")).toBeTruthy() + expect(screen.getByText("Test Note")).toBeTruthy() + }) + + it("renders Delete note item when onDelete callback is provided and invokes it on click", () => { + const onDelete = jest.fn() + render() + + const deleteItem = screen.getByText("Delete note") + expect(deleteItem).toBeTruthy() + + fireEvent.click(deleteItem) + + expect(onDelete).toHaveBeenCalledTimes(1) + }) + + it("supports closing menu via RAG panel callback", () => { + render() + + const menu = screen.getByTestId("dropdown-menu") + const closeBtn = screen.getByTestId("close-menu-from-rag") + + fireEvent.click(closeBtn) + + expect(menu.getAttribute("data-open")).toBe("false") + }) +}) diff --git a/ui/web/tests/unit/components/features/notes/Sidebar.test.tsx b/ui/web/tests/unit/components/features/notes/Sidebar.test.tsx new file mode 100644 index 00000000000..ab20ad39162 --- /dev/null +++ b/ui/web/tests/unit/components/features/notes/Sidebar.test.tsx @@ -0,0 +1,249 @@ +import * as React from "react" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { Sidebar } from "@/components/features/notes/Sidebar" +import type { User } from "@supabase/supabase-js" + +const mockSetTheme = jest.fn() + +jest.mock("next-themes", () => ({ + useTheme: () => ({ + theme: "light", + setTheme: mockSetTheme, + resolvedTheme: "light", + }), +})) + +const mockUser = { + id: "user-123", + email: "alex.dev@example.com", + app_metadata: {}, + user_metadata: {}, + aud: "authenticated", + created_at: "2026-01-01T00:00:00Z", +} as User + +describe("Sidebar", () => { + const defaultProps = { + user: mockUser, + notesDisplayed: 5, + notesTotal: 20, + pendingCount: 0, + failedCount: 0, + isOffline: false, + selectionMode: false, + selectedCount: 0, + bulkDeleting: false, + onExitSelectionMode: jest.fn(), + onSelectAll: jest.fn(), + onBulkDelete: jest.fn(), + filterByTag: null, + onClearTagFilter: jest.fn(), + onOpenSearch: jest.fn(), + onOpenSettings: jest.fn(), + onCreateNote: jest.fn(), + onSignOut: jest.fn(), + children:
Notes List Content
, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("Brand & Theme Header", () => { + it("renders EverFreeNote title and theme toggle", () => { + render() + + const titleHeading = screen.getByRole("heading", { level: 1 }) + expect(titleHeading.textContent).toBe("EverFreeNote") + expect(screen.getByRole("button", { name: "Toggle theme" })).toBeTruthy() + }) + + it("handles theme toggle click", async () => { + render() + + const themeBtn = screen.getByRole("button", { name: "Toggle theme" }) + fireEvent.click(themeBtn) + + await waitFor(() => { + expect(mockSetTheme).toHaveBeenCalledWith("dark") + }) + }) + }) + + describe("Sync Status Indicator", () => { + it("renders Synchronized dot when online and counts are zero", () => { + const { container } = render() + + const statusOutput = container.querySelector('output[aria-label="Synchronized"]') + expect(statusOutput).toBeTruthy() + expect(statusOutput?.className).toContain("bg-emerald-500") + }) + + it("renders Offline mode status dot when isOffline is true", () => { + const { container } = render() + + const statusOutput = container.querySelector('output[aria-label="Offline mode"]') + expect(statusOutput).toBeTruthy() + expect(statusOutput?.className).toContain("bg-amber-500") + }) + + it("renders Syncing count status dot when pendingCount > 0", () => { + const { container } = render() + + const statusOutput = container.querySelector('output[aria-label="Syncing: 3"]') + expect(statusOutput).toBeTruthy() + expect(statusOutput?.className).toContain("bg-muted-foreground") + }) + + it("renders Sync failed count status dot when failedCount > 0", () => { + const { container } = render() + + const statusOutput = container.querySelector('output[aria-label="Sync failed: 2"]') + expect(statusOutput).toBeTruthy() + expect(statusOutput?.className).toContain("bg-destructive") + }) + }) + + describe("Search & Navigation Actions", () => { + it("triggers onOpenSearch when search trigger is clicked", () => { + const onOpenSearch = jest.fn() + render() + + const searchTrigger = screen.getByTestId("sidebar-search-trigger") + fireEvent.click(searchTrigger) + + expect(onOpenSearch).toHaveBeenCalledTimes(1) + }) + + it("triggers onCreateNote when New Note button is clicked", () => { + const onCreateNote = jest.fn() + render() + + const newNoteBtn = screen.getByRole("button", { name: /New Note/i }) + fireEvent.click(newNoteBtn) + + expect(onCreateNote).toHaveBeenCalledTimes(1) + }) + + it("formats notes count text correctly", () => { + render() + expect(screen.getByText("7 of 15 notes")).toBeTruthy() + }) + + it("renders fallback text when notes count props are omitted", () => { + render() + expect(screen.getByText("- of unknown notes")).toBeTruthy() + }) + }) + + describe("Selection Mode & Bulk Delete Confirmation", () => { + it("renders SelectionModeActions when selectionMode is true", () => { + render( + + ) + + expect(screen.getByTestId("selection-mode-count").textContent).toBe("3") + }) + + it("triggers onSelectAll when select all button is clicked in selection mode", () => { + const onSelectAll = jest.fn() + render( + + ) + + const selectAllBtn = screen.getByTestId("selection-mode-select-all") + fireEvent.click(selectAllBtn) + + expect(onSelectAll).toHaveBeenCalledTimes(1) + }) + + it("opens BulkDeleteDialog when bulk delete button is clicked and confirms bulk delete", async () => { + const onBulkDelete = jest.fn() + render( + + ) + + const deleteBtn = screen.getByTestId("selection-mode-delete") + fireEvent.click(deleteBtn) + + // Dialog opens + expect(screen.getByTestId("bulk-delete-dialog")).toBeTruthy() + expect(screen.getByText(/This action will delete 2 notes\./i)).toBeTruthy() + + const input = screen.getByTestId("bulk-delete-confirm-input") + fireEvent.change(input, { target: { value: "2" } }) + + const confirmBtn = screen.getByTestId("bulk-delete-confirm") + fireEvent.click(confirmBtn) + + await waitFor(() => { + expect(onBulkDelete).toHaveBeenCalledTimes(1) + }) + }) + + it("triggers onExitSelectionMode when cancel button is clicked", () => { + const onExitSelectionMode = jest.fn() + render( + + ) + + const cancelBtn = screen.getByRole("button", { name: /Cancel/i }) + fireEvent.click(cancelBtn) + + expect(onExitSelectionMode).toHaveBeenCalledTimes(1) + }) + }) + + describe("Children & User Profile Section", () => { + it("renders children in notes list container", () => { + render() + expect(screen.getByTestId("notes-list-children")).toBeTruthy() + }) + + it("renders user initial avatar and email address", () => { + render() + + expect(screen.getByText("A")).toBeTruthy() + expect(screen.getByText("alex.dev@example.com")).toBeTruthy() + }) + + it("triggers onOpenSettings when settings button is clicked", () => { + const onOpenSettings = jest.fn() + render() + + const settingsBtn = screen.getByRole("button", { name: "Open settings page" }) + fireEvent.click(settingsBtn) + + expect(onOpenSettings).toHaveBeenCalledTimes(1) + }) + + it("triggers onSignOut when sign out button is clicked", () => { + const onSignOut = jest.fn() + render() + + const signOutBtn = screen.getByRole("button", { name: "Sign out" }) + fireEvent.click(signOutBtn) + + expect(onSignOut).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/ui/web/tests/unit/components/features/public/PublicSharePageClient.test.tsx b/ui/web/tests/unit/components/features/public/PublicSharePageClient.test.tsx new file mode 100644 index 00000000000..355e571a662 --- /dev/null +++ b/ui/web/tests/unit/components/features/public/PublicSharePageClient.test.tsx @@ -0,0 +1,159 @@ +import React from "react" +import { render, screen, waitFor, act } from "@testing-library/react" + +import { PublicSharePageClient } from "@/components/features/public/PublicSharePageClient" +import type { PublicNote } from "@core/services/publicNoteShare" + +const mockGetSearchParams = jest.fn() +jest.mock("next/navigation", () => ({ + useSearchParams: () => ({ + get: mockGetSearchParams, + }), +})) + +const mockSupabase = {} +jest.mock("@ui/web/providers/SupabaseProvider", () => ({ + useSupabase: () => ({ supabase: mockSupabase }), +})) + +const mockGetPublicNoteByToken = jest.fn() +jest.mock("@core/services/publicNoteShare", () => ({ + PublicNoteShareService: jest.fn().mockImplementation(() => ({ + getPublicNoteByToken: mockGetPublicNoteByToken, + })), +})) + +jest.mock("@/components/theme-toggle", () => ({ + ThemeToggle: () => , +})) + +const makePublicNote = (overrides: Partial = {}): PublicNote => ({ + token: "valid-token-123", + title: "Shared Test Note", + description: "

This is a public note markdown content.

", + tags: ["public", "test"], + created_at: "2026-04-28T10:00:00.000Z", + updated_at: "2026-04-28T12:00:00.000Z", + ...overrides, +}) + +describe("PublicSharePageClient", () => { + beforeEach(() => { + jest.clearAllMocks() + mockGetSearchParams.mockReturnValue("valid-token-123") + }) + + it("shows loading state initially while note is fetching", () => { + mockGetPublicNoteByToken.mockImplementation(() => new Promise(() => undefined)) + + render() + + expect(screen.getByRole("heading", { name: "Loading shared note" })).toBeTruthy() + expect(screen.getByText("One moment while the note opens.")).toBeTruthy() + expect(screen.getByRole("button", { name: "Toggle theme" })).toBeTruthy() + }) + + it("loads and displays the public note with title, tags, formatted date, and markdown content", async () => { + const note = makePublicNote() + mockGetPublicNoteByToken.mockResolvedValue(note) + + render() + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Shared Test Note" })).toBeTruthy() + }) + + expect(mockGetPublicNoteByToken).toHaveBeenCalledWith("valid-token-123") + expect(screen.getByText("public note")).toBeTruthy() + expect(screen.getByText("public")).toBeTruthy() + expect(screen.getByText("test")).toBeTruthy() + expect(screen.getByText(/Shared note/i)).toBeTruthy() + expect(screen.getByRole("button", { name: "Toggle theme" })).toBeTruthy() + }) + + it("handles missing token in search parameters as not-found state", async () => { + mockGetSearchParams.mockReturnValue("") + + render() + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Note not available" })).toBeTruthy() + }) + + expect(screen.getByText("This shared note link is missing, inactive, or no longer available.")).toBeTruthy() + expect(mockGetPublicNoteByToken).not.toHaveBeenCalled() + }) + + it("handles null search params token as not-found state", async () => { + mockGetSearchParams.mockReturnValue(null) + + render() + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Note not available" })).toBeTruthy() + }) + + expect(screen.getByText("This shared note link is missing, inactive, or no longer available.")).toBeTruthy() + expect(mockGetPublicNoteByToken).not.toHaveBeenCalled() + }) + + it("displays not-found message when getPublicNoteByToken returns null", async () => { + mockGetPublicNoteByToken.mockResolvedValue(null) + + render() + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Note not available" })).toBeTruthy() + }) + + expect(screen.getByText("This shared note link is missing, inactive, or no longer available.")).toBeTruthy() + }) + + it("displays error message when getPublicNoteByToken rejects with Error", async () => { + mockGetPublicNoteByToken.mockRejectedValue(new Error("Database connection lost")) + + render() + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Could not load note" })).toBeTruthy() + }) + + expect(screen.getByText("Database connection lost")).toBeTruthy() + }) + + it("displays fallback error message when getPublicNoteByToken rejects with non-Error object", async () => { + mockGetPublicNoteByToken.mockRejectedValue("Unknown rejection") + + render() + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Could not load note" })).toBeTruthy() + }) + + expect(screen.getByText("Could not load this shared note.")).toBeTruthy() + }) + + it("cancels state updates cleanly when unmounted before request resolves", async () => { + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}) + + let resolvePromise!: (value: PublicNote | null) => void + const pendingPromise = new Promise((resolve) => { + resolvePromise = resolve + }) + + mockGetPublicNoteByToken.mockReturnValue(pendingPromise) + + const { unmount } = render() + + // Unmount before promise resolves + unmount() + + // Resolve after unmount - should not trigger console.error warning + await act(async () => { + resolvePromise(makePublicNote()) + }) + + expect(consoleErrorSpy).not.toHaveBeenCalled() + consoleErrorSpy.mockRestore() + }) +}) diff --git a/ui/web/tests/unit/components/features/search/AiSearchPresetSelector.test.tsx b/ui/web/tests/unit/components/features/search/AiSearchPresetSelector.test.tsx new file mode 100644 index 00000000000..d6299af3ac9 --- /dev/null +++ b/ui/web/tests/unit/components/features/search/AiSearchPresetSelector.test.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { AiSearchPresetSelector } from '@ui/web/components/features/search/AiSearchPresetSelector' +import { DEFAULT_PRESET } from '@core/constants/aiSearch' + +describe('AiSearchPresetSelector', () => { + it('renders search precision toggle group with all preset options', () => { + const onChange = jest.fn() + render() + + const group = screen.getByRole('group', { name: 'Search precision' }) + expect(group).toBeTruthy() + + const strictBtn = screen.getByRole('radio', { name: 'Strict' }) + const neutralBtn = screen.getByRole('radio', { name: 'Neutral' }) + const broadBtn = screen.getByRole('radio', { name: 'Broad' }) + + expect(strictBtn).toBeTruthy() + expect(neutralBtn).toBeTruthy() + expect(broadBtn).toBeTruthy() + }) + + it('highlights the active preset selection', () => { + const onChange = jest.fn() + const { rerender } = render() + + const strictBtn = screen.getByRole('radio', { name: 'Strict' }) + const neutralBtn = screen.getByRole('radio', { name: 'Neutral' }) + const broadBtn = screen.getByRole('radio', { name: 'Broad' }) + + expect(strictBtn.getAttribute('data-state')).toBe('on') + expect(neutralBtn.getAttribute('data-state')).toBe('off') + expect(broadBtn.getAttribute('data-state')).toBe('off') + + rerender() + + expect(strictBtn.getAttribute('data-state')).toBe('off') + expect(neutralBtn.getAttribute('data-state')).toBe('off') + expect(broadBtn.getAttribute('data-state')).toBe('on') + }) + + it('correctly uses DEFAULT_PRESET as active selection when passed', () => { + const onChange = jest.fn() + render() + + const neutralBtn = screen.getByRole('radio', { name: 'Neutral' }) + expect(neutralBtn.getAttribute('data-state')).toBe('on') + }) + + it('triggers onChange callback with the selected preset when clicked', () => { + const onChange = jest.fn() + render() + + const strictBtn = screen.getByRole('radio', { name: 'Strict' }) + fireEvent.click(strictBtn) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith('strict') + + const broadBtn = screen.getByRole('radio', { name: 'Broad' }) + fireEvent.click(broadBtn) + + expect(onChange).toHaveBeenCalledTimes(2) + expect(onChange).toHaveBeenLastCalledWith('broad') + }) + + it('does not trigger onChange when clicking the currently active preset (unchecking ignored)', () => { + const onChange = jest.fn() + render() + + const neutralBtn = screen.getByRole('radio', { name: 'Neutral' }) + fireEvent.click(neutralBtn) + + expect(onChange).not.toHaveBeenCalled() + }) +}) diff --git a/ui/web/tests/unit/components/features/search/ChunkSearchItem.test.tsx b/ui/web/tests/unit/components/features/search/ChunkSearchItem.test.tsx new file mode 100644 index 00000000000..7a6a24d7446 --- /dev/null +++ b/ui/web/tests/unit/components/features/search/ChunkSearchItem.test.tsx @@ -0,0 +1,158 @@ +import * as React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { ChunkSearchItem } from '@ui/web/components/features/search/ChunkSearchItem' +import type { RagChunk } from '@core/types/ragSearch' + +function makeRagChunk(overrides: Partial = {}): RagChunk { + return { + noteId: 'note-100', + noteTitle: 'React Testing Principles', + noteTags: ['testing', 'react'], + chunkIndex: 0, + charOffset: 25, + bodyContent: 'Unit tests ensure logic correctness and prevent regressions.', + overlapPrefix: '', + content: 'Unit tests ensure logic correctness and prevent regressions.', + similarity: 0.85, + ...overrides, + } +} + +describe('ChunkSearchItem', () => { + const defaultProps = { + onOpenInContext: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering note title & content snippet', () => { + it('renders note title and body content correctly', () => { + const chunk = makeRagChunk({ + noteTitle: 'Architecture Overview', + bodyContent: 'Clean architecture decouples UI from business domain.', + }) + + render() + + const titleHeading = screen.getByRole('heading', { level: 3 }) + expect(titleHeading.textContent).toBe('Architecture Overview') + expect(screen.getByText('Clean architecture decouples UI from business domain.')).toBeTruthy() + }) + + it('falls back to "Untitled" when noteTitle is empty string or falsy', () => { + const chunk = makeRagChunk({ + noteTitle: '', + }) + + render() + + const titleHeading = screen.getByRole('heading', { level: 3 }) + expect(titleHeading.textContent).toBe('Untitled') + expect(screen.getByRole('button').getAttribute('aria-label')).toBe('Open fragment from "Untitled" in context') + }) + + it('falls back to content property when bodyContent is missing or null', () => { + const chunk = makeRagChunk({ + // @ts-expect-error testing runtime fallback + bodyContent: null, + content: 'Fallback content snippet text.', + }) + + render() + + expect(screen.getByText('Fallback content snippet text.')).toBeTruthy() + }) + }) + + describe('Similarity score formatting & styling tiers', () => { + it('renders similarity score percentage and emerald styling for score >= 0.8', () => { + const chunk = makeRagChunk({ similarity: 0.92 }) + + const { container } = render() + + expect(screen.getByText('92%')).toBeTruthy() + expect(screen.getByText('92%').className).toContain('text-emerald-400') + expect(container.querySelector('[role="listitem"]')?.className).toContain('border-l-emerald-500') + }) + + it('renders similarity score percentage and amber styling for score >= 0.65 and < 0.8', () => { + const chunk = makeRagChunk({ similarity: 0.72 }) + + const { container } = render() + + expect(screen.getByText('72%')).toBeTruthy() + expect(screen.getByText('72%').className).toContain('text-amber-400') + expect(container.querySelector('[role="listitem"]')?.className).toContain('border-l-amber-500') + }) + + it('renders default muted styling for score < 0.65', () => { + const chunk = makeRagChunk({ similarity: 0.45 }) + + const { container } = render() + + expect(screen.getByText('45%')).toBeTruthy() + expect(screen.getByText('45%').className).toContain('text-muted-foreground/60') + expect(container.querySelector('[role="listitem"]')?.className).toContain('border-l-border') + }) + }) + + describe('Content snippet highlighting', () => { + it('highlights matching search terms in snippet', () => { + const chunk = makeRagChunk({ + bodyContent: 'TypeScript interfaces provide strong type safety.', + }) + + render() + + const marks = screen.getAllByRole('mark') + expect(marks).toHaveLength(2) + expect(marks[0].textContent).toBe('interfaces') + expect(marks[1].textContent).toBe('safety') + }) + }) + + describe('Click and Keyboard Interaction Handlers', () => { + it('triggers onOpenInContext callback when item is clicked', () => { + const onOpenInContext = jest.fn() + const chunk = makeRagChunk({ + noteId: 'note-456', + charOffset: 50, + bodyContent: 'Sample text for testing offset calculation.', + }) + + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + expect(onOpenInContext).toHaveBeenCalledTimes(1) + expect(onOpenInContext).toHaveBeenCalledWith('note-456', 50, expect.any(Number)) + }) + + it('triggers onOpenInContext when Enter or Space key is pressed', () => { + const onOpenInContext = jest.fn() + const chunk = makeRagChunk({ + noteId: 'note-789', + charOffset: 10, + bodyContent: 'Keyboard navigation test snippet.', + }) + + render() + + const button = screen.getByRole('button') + + fireEvent.keyDown(button, { key: 'Enter' }) + expect(onOpenInContext).toHaveBeenCalledTimes(1) + expect(onOpenInContext).toHaveBeenCalledWith('note-789', 10, expect.any(Number)) + + fireEvent.keyDown(button, { key: ' ' }) + expect(onOpenInContext).toHaveBeenCalledTimes(2) + + // Other keys should not trigger callback + fireEvent.keyDown(button, { key: 'Tab' }) + expect(onOpenInContext).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/ui/web/tests/unit/components/features/search/NoteSearchResults.test.tsx b/ui/web/tests/unit/components/features/search/NoteSearchResults.test.tsx new file mode 100644 index 00000000000..8b17c12a9ff --- /dev/null +++ b/ui/web/tests/unit/components/features/search/NoteSearchResults.test.tsx @@ -0,0 +1,263 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { NoteSearchResults } from '@ui/web/components/features/search/NoteSearchResults' +import type { RagChunk, RagNoteGroup } from '@core/types/ragSearch' + +function makeChunk(overrides: Partial = {}): RagChunk { + return { + noteId: 'note-1', + noteTitle: 'First Note Title', + noteTags: ['react', 'testing'], + chunkIndex: 0, + charOffset: 10, + bodyContent: 'First chunk body content', + overlapPrefix: '', + content: 'First chunk body content', + similarity: 0.92, + ...overrides, + } +} + +function makeGroup(overrides: Partial = {}): RagNoteGroup { + return { + noteId: 'note-1', + noteTitle: 'First Note Title', + noteTags: ['react', 'testing'], + topScore: 0.92, + chunks: [makeChunk()], + hiddenCount: 0, + ...overrides, + } +} + +describe('NoteSearchResults', () => { + const mockOnOpenInContext = jest.fn() + const mockOnTagClick = jest.fn() + const mockOnToggleSelect = jest.fn() + const mockOnLoadMore = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Empty Search State', () => { + it('renders empty search state message when noteGroups array is empty', () => { + render( + + ) + + expect( + screen.getByText( + /No results\. Lower the precision slider or use the/i + ) + ).toBeTruthy() + expect(screen.queryByRole('list')).toBeNull() + }) + }) + + describe('Search Results List & Metadata Display', () => { + it('renders list container with correct role and aria-label', () => { + const groups = [makeGroup()] + + render( + + ) + + const listContainer = screen.getByRole('list', { name: 'Note search results' }) + expect(listContainer).toBeTruthy() + }) + + it('renders multiple note search items with titles, metadata scores, and tags', () => { + const groups: RagNoteGroup[] = [ + makeGroup({ + noteId: 'note-1', + noteTitle: 'React Integration Guide', + topScore: 0.95, + noteTags: ['frontend', 'react'], + chunks: [ + makeChunk({ + noteId: 'note-1', + noteTitle: 'React Integration Guide', + bodyContent: 'React component setup instructions', + similarity: 0.95, + }), + ], + }), + makeGroup({ + noteId: 'note-2', + noteTitle: 'TypeScript System Docs', + topScore: 0.78, + noteTags: ['typescript'], + chunks: [ + makeChunk({ + noteId: 'note-2', + noteTitle: 'TypeScript System Docs', + bodyContent: 'Type definitions and compiler options', + similarity: 0.78, + }), + ], + }), + ] + + render( + + ) + + expect(screen.getByText('React Integration Guide')).toBeTruthy() + expect(screen.getByText('TypeScript System Docs')).toBeTruthy() + expect(screen.getByText('95%')).toBeTruthy() + expect(screen.getByText('78%')).toBeTruthy() + expect(screen.getByText('frontend')).toBeTruthy() + expect(screen.getByText('typescript')).toBeTruthy() + expect(screen.getByText('React component setup instructions')).toBeTruthy() + expect(screen.getByText('Type definitions and compiler options')).toBeTruthy() + }) + + it('invokes onOpenInContext callback when clicking open fragment on a result item', () => { + const groups = [makeGroup({ noteId: 'note-42' })] + + render( + + ) + + const openButton = screen.getByRole('button', { name: /Open top fragment/i }) + fireEvent.click(openButton) + + expect(mockOnOpenInContext).toHaveBeenCalledTimes(1) + expect(mockOnOpenInContext).toHaveBeenCalledWith('note-42', 10, expect.any(Number)) + }) + + it('invokes onTagClick callback when clicking an interactive tag', () => { + const groups = [makeGroup({ noteTags: ['architecture'] })] + + render( + + ) + + const tagElement = screen.getByText('architecture') + fireEvent.click(tagElement) + + expect(mockOnTagClick).toHaveBeenCalledTimes(1) + expect(mockOnTagClick).toHaveBeenCalledWith('architecture') + }) + }) + + describe('Selection Mode & Callback', () => { + it('passes selectionMode and isSelected state to result items', () => { + const groups = [ + makeGroup({ noteId: 'note-1', noteTitle: 'Note One' }), + makeGroup({ noteId: 'note-2', noteTitle: 'Note Two' }), + ] + const selectedIds = new Set(['note-1']) + + render( + + ) + + const checkboxes = screen.getAllByRole('checkbox') + expect(checkboxes).toHaveLength(2) + expect(checkboxes[0].getAttribute("aria-checked")).toBe("true") + expect(checkboxes[1].getAttribute("aria-checked")).toBe("false") + }) + + it('triggers onToggleSelect callback when toggling selection on an item', () => { + const groups = [makeGroup({ noteId: 'note-10' })] + + render( + + ) + + const checkbox = screen.getByRole('checkbox') + fireEvent.click(checkbox) + + expect(mockOnToggleSelect).toHaveBeenCalledTimes(1) + expect(mockOnToggleSelect).toHaveBeenCalledWith('note-10') + }) + }) + + describe('Pagination & Load More State', () => { + it('renders Load More button when hasMore is true and invokes onLoadMore on click', () => { + const groups = [makeGroup()] + + render( + + ) + + const loadMoreButton = screen.getByRole('button', { name: /Load more\.\.\./i }) + expect(loadMoreButton).toBeTruthy() + + fireEvent.click(loadMoreButton) + expect(mockOnLoadMore).toHaveBeenCalledTimes(1) + }) + + it('renders loading spinner when loadingMore is true and hides Load More button', () => { + const groups = [makeGroup()] + + const { container } = render( + + ) + + expect(screen.queryByRole('button', { name: /Load more\.\.\./i })).toBeNull() + const spinner = container.querySelector('.animate-spin') + expect(spinner).toBeTruthy() + }) + + it('does not render Load More button or spinner when hasMore is false and loadingMore is false', () => { + const groups = [makeGroup()] + + const { container } = render( + + ) + + expect(screen.queryByRole('button', { name: /Load more\.\.\./i })).toBeNull() + expect(container.querySelector('.animate-spin')).toBeNull() + }) + }) +}) diff --git a/ui/web/tests/unit/components/features/settings/DeleteAccountPanel.test.tsx b/ui/web/tests/unit/components/features/settings/DeleteAccountPanel.test.tsx new file mode 100644 index 00000000000..24fc1cabd03 --- /dev/null +++ b/ui/web/tests/unit/components/features/settings/DeleteAccountPanel.test.tsx @@ -0,0 +1,151 @@ +import React from "react" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" + +import { DeleteAccountPanel } from "@/components/features/settings/DeleteAccountPanel" + +describe("DeleteAccountPanel", () => { + const defaultProps = { + email: "user@example.com", + onConfirm: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("renders email, warning header, permanent action warning text, and checkbox label", () => { + render() + + expect(screen.getByText("user@example.com")).toBeTruthy() + expect(screen.getByText("Permanent action")).toBeTruthy() + expect( + screen.getByText( + "This will permanently delete your account and all notes. Export your notes before deleting the account if you need a copy." + ) + ).toBeTruthy() + expect( + screen.getByText("I understand that my account and all notes will be permanently deleted.") + ).toBeTruthy() + }) + + it("renders fallback text when email is missing or null", () => { + render() + + expect(screen.getByText("No email available")).toBeTruthy() + }) + + it("keeps the delete button disabled until the acknowledgment checkbox is checked", () => { + render() + + const deleteButton = screen.getByRole("button", { name: "Delete account" }) as HTMLButtonElement + const checkbox = screen.getByRole("checkbox", { + name: "I understand that my account and all notes will be permanently deleted.", + }) + + expect(deleteButton.disabled).toBe(true) + + fireEvent.click(checkbox) + expect(deleteButton.disabled).toBe(false) + + fireEvent.click(checkbox) + expect(deleteButton.disabled).toBe(true) + }) + + it("reflects external loading prop on delete button state and label", () => { + const { rerender } = render() + + const checkbox = screen.getByRole("checkbox") + fireEvent.click(checkbox) + + const deleteButton = screen.getByRole("button", { name: "Delete account" }) as HTMLButtonElement + expect(deleteButton.disabled).toBe(false) + + rerender() + + const loadingButton = screen.getByRole("button", { name: "Deleting..." }) as HTMLButtonElement + expect(loadingButton.disabled).toBe(true) + }) + + it("executes delete confirmation on button click and unchecks acknowledgment on success", async () => { + let resolveDelete!: () => void + const deletePromise = new Promise((resolve) => { + resolveDelete = resolve + }) + const onConfirmMock = jest.fn().mockReturnValue(deletePromise) + + render() + + const checkbox = screen.getByRole("checkbox") + fireEvent.click(checkbox) + + const deleteButton = screen.getByRole("button", { name: "Delete account" }) + fireEvent.click(deleteButton) + + expect(onConfirmMock).toHaveBeenCalledTimes(1) + expect(screen.getByRole("button", { name: "Deleting..." })).toBeTruthy() + + resolveDelete() + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Delete account" })).toBeTruthy() + }) + + const buttonAfter = screen.getByRole("button", { name: "Delete account" }) as HTMLButtonElement + expect(buttonAfter.disabled).toBe(true) + }) + + it("displays error message when onConfirm rejects with an Error object", async () => { + const onConfirmMock = jest.fn().mockRejectedValue(new Error("Server error during account deletion")) + + render() + + const checkbox = screen.getByRole("checkbox") + fireEvent.click(checkbox) + + fireEvent.click(screen.getByRole("button", { name: "Delete account" })) + + await waitFor(() => { + expect(screen.getByText("Server error during account deletion")).toBeTruthy() + }) + const deleteBtnError = screen.getByRole("button", { name: "Delete account" }) as HTMLButtonElement + expect(deleteBtnError).toBeTruthy() + expect(deleteBtnError.disabled).toBe(false) + }) + + it("displays fallback error message when onConfirm rejects with a non-Error value", async () => { + const onConfirmMock = jest.fn().mockRejectedValue("unexpected error") + + render() + + const checkbox = screen.getByRole("checkbox") + fireEvent.click(checkbox) + + fireEvent.click(screen.getByRole("button", { name: "Delete account" })) + + await waitFor(() => { + expect(screen.getByText("Failed to delete account. Please try again.")).toBeTruthy() + }) + const deleteBtnFallback = screen.getByRole("button", { name: "Delete account" }) as HTMLButtonElement + expect(deleteBtnFallback).toBeTruthy() + expect(deleteBtnFallback.disabled).toBe(false) + }) + + it("clears displayed error message when checkbox state changes", async () => { + const onConfirmMock = jest.fn().mockRejectedValue(new Error("Temporary deletion error")) + + render() + + const checkbox = screen.getByRole("checkbox") + fireEvent.click(checkbox) + + fireEvent.click(screen.getByRole("button", { name: "Delete account" })) + + await waitFor(() => { + expect(screen.getByText("Temporary deletion error")).toBeTruthy() + }) + + // Unchecking checkbox clears error + fireEvent.click(checkbox) + expect(screen.queryByText("Temporary deletion error")).toBeNull() + }) +}) diff --git a/ui/web/tests/unit/components/features/wordpress/ExportToWordPressButton.test.tsx b/ui/web/tests/unit/components/features/wordpress/ExportToWordPressButton.test.tsx new file mode 100644 index 00000000000..253eac2e26a --- /dev/null +++ b/ui/web/tests/unit/components/features/wordpress/ExportToWordPressButton.test.tsx @@ -0,0 +1,151 @@ +import * as React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import { ExportToWordPressButton, type ExportableWordPressNote } from "@ui/web/components/features/wordpress/ExportToWordPressButton" +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" + +describe("ExportToWordPressButton", () => { + const sampleNote: ExportableWordPressNote = { + id: "wp-note-1", + title: "WordPress Note Title", + description: "

Note Body Description

", + tags: ["wordpress", "blog"], + } + + const defaultProps = { + getNote: jest.fn(() => sampleNote), + onRequestExport: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("Button Trigger Variant", () => { + it("renders default button with 'Export to WP' label", () => { + render() + + const btn = screen.getByRole("button", { name: /Export to WP/i }) as HTMLButtonElement + expect(btn).toBeTruthy() + expect(btn.disabled).toBe(false) + }) + + it("renders custom label and custom variant when provided", () => { + render( + + ) + + const btn = screen.getByRole("button", { name: "Publish to Blog" }) + expect(btn).toBeTruthy() + expect(btn.className).toContain("bg-secondary") + }) + + it("triggers onRequestExport with note data when clicked", () => { + const onRequestExport = jest.fn() + const getNote = jest.fn(() => sampleNote) + + render( + + ) + + const btn = screen.getByRole("button", { name: /Export to WP/i }) + fireEvent.click(btn) + + expect(getNote).toHaveBeenCalledTimes(1) + expect(onRequestExport).toHaveBeenCalledTimes(1) + expect(onRequestExport).toHaveBeenCalledWith(sampleNote) + }) + + it("does not call onRequestExport if getNote returns null or note without id", () => { + const onRequestExport = jest.fn() + const getNoteNull = jest.fn(() => null) + + const { rerender } = render( + + ) + + const btn = screen.getByRole("button", { name: /Export to WP/i }) + fireEvent.click(btn) + + expect(getNoteNull).toHaveBeenCalledTimes(1) + expect(onRequestExport).not.toHaveBeenCalled() + + const getNoteNoId = jest.fn(() => ({ title: "No ID Note" } as unknown as ExportableWordPressNote)) + + rerender( + + ) + + fireEvent.click(btn) + + expect(getNoteNoId).toHaveBeenCalledTimes(1) + expect(onRequestExport).not.toHaveBeenCalled() + }) + + it("disables the button when disabled prop is true", () => { + render() + + const btn = screen.getByRole("button", { name: /Export to WP/i }) as HTMLButtonElement + expect(btn.disabled).toBe(true) + }) + }) + + describe("Menu Item Trigger Variant", () => { + it("renders as DropdownMenuItem inside DropdownMenuContent and triggers export on select", () => { + const onRequestExport = jest.fn() + const getNote = jest.fn(() => sampleNote) + + render( + + Open + + + + + ) + + const menuItem = screen.getByRole("menuitem", { name: "Export from Menu" }) + expect(menuItem).toBeTruthy() + + fireEvent.click(menuItem) + + expect(getNote).toHaveBeenCalledTimes(1) + expect(onRequestExport).toHaveBeenCalledWith(sampleNote) + }) + + it("disables menu item when disabled prop is true", () => { + render( + + Open + + + + + ) + + const menuItem = screen.getByRole("menuitem", { name: "Export to WP" }) + expect(menuItem.getAttribute("data-disabled")).not.toBeNull() + }) + }) +}) diff --git a/ui/web/tests/unit/components/theme-toggle.test.tsx b/ui/web/tests/unit/components/theme-toggle.test.tsx new file mode 100644 index 00000000000..8d955bc68c9 --- /dev/null +++ b/ui/web/tests/unit/components/theme-toggle.test.tsx @@ -0,0 +1,102 @@ +import React from "react" +import { fireEvent, render, screen } from "@testing-library/react" +import { useTheme } from "next-themes" + +import { ThemeToggle } from "@/components/theme-toggle" + +const mockSetTheme = jest.fn() + +jest.mock("next-themes", () => ({ + useTheme: jest.fn(), +})) + +describe("ThemeToggle", () => { + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(useTheme).mockReturnValue({ + theme: "light", + setTheme: mockSetTheme, + resolvedTheme: "light", + themes: ["light", "dark", "system"], + systemTheme: "light", + }) + }) + + it("renders light mode theme toggle button with accessible label and Moon icon", () => { + render() + + const button = screen.getByRole("button", { name: /toggle theme/i }) + expect(button).toBeTruthy() + expect(button.getAttribute("title")).toBe("Switch to dark mode") + }) + + it("switches theme from light to dark on click", () => { + render() + + const button = screen.getByRole("button", { name: /toggle theme/i }) + fireEvent.click(button) + + expect(mockSetTheme).toHaveBeenCalledWith("dark") + }) + + it("renders dark mode theme toggle with Sun icon and switches from dark to light on click", () => { + jest.mocked(useTheme).mockReturnValue({ + theme: "dark", + setTheme: mockSetTheme, + resolvedTheme: "dark", + themes: ["light", "dark", "system"], + systemTheme: "dark", + }) + + render() + + const button = screen.getByRole("button", { name: /toggle theme/i }) + expect(button.getAttribute("title")).toBe("Switch to light mode") + + fireEvent.click(button) + expect(mockSetTheme).toHaveBeenCalledWith("light") + }) + + it("handles system theme when resolvedTheme is dark", () => { + jest.mocked(useTheme).mockReturnValue({ + theme: "system", + setTheme: mockSetTheme, + resolvedTheme: "dark", + themes: ["light", "dark", "system"], + systemTheme: "dark", + }) + + render() + + const button = screen.getByRole("button", { name: /toggle theme/i }) + expect(button.getAttribute("title")).toBe("Switch to light mode") + + fireEvent.click(button) + expect(mockSetTheme).toHaveBeenCalledWith("light") + }) + + it("handles system theme when resolvedTheme is light", () => { + jest.mocked(useTheme).mockReturnValue({ + theme: "system", + setTheme: mockSetTheme, + resolvedTheme: "light", + themes: ["light", "dark", "system"], + systemTheme: "light", + }) + + render() + + const button = screen.getByRole("button", { name: /toggle theme/i }) + expect(button.getAttribute("title")).toBe("Switch to dark mode") + + fireEvent.click(button) + expect(mockSetTheme).toHaveBeenCalledWith("dark") + }) + + it("renders theme toggle button with toggle theme aria-label", () => { + render() + + const button = screen.getByRole("button", { name: "Toggle theme" }) + expect(button).toBeTruthy() + }) +}) diff --git a/ui/web/tests/unit/hooks/use-mobile.test.tsx b/ui/web/tests/unit/hooks/use-mobile.test.tsx new file mode 100644 index 00000000000..9a566120bcd --- /dev/null +++ b/ui/web/tests/unit/hooks/use-mobile.test.tsx @@ -0,0 +1,131 @@ +import { renderHook, act } from "@testing-library/react" +import { useIsMobile } from "@ui/web/hooks/use-mobile" + +function setupMatchMedia(initialMatches = false) { + const listeners = new Set<(e: Event) => void>() + + const mediaQueryList = { + matches: initialMatches, + media: "(max-width: 767px)", + onchange: null, + addEventListener: jest.fn((event: string, callback: (e: Event) => void) => { + if (event === "change") { + listeners.add(callback) + } + }), + removeEventListener: jest.fn((event: string, callback: (e: Event) => void) => { + if (event === "change") { + listeners.delete(callback) + } + }), + dispatchEvent: jest.fn(), + } as unknown as MediaQueryList + + const matchMediaMock = jest.fn((query: string) => { + return { + ...mediaQueryList, + media: query, + } + }) + + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: matchMediaMock, + }) + + const triggerChange = () => { + listeners.forEach((listener) => listener(new Event("change"))) + } + + return { + mediaQueryList, + matchMediaMock, + listeners, + triggerChange, + } +} + +describe("useIsMobile", () => { + const originalInnerWidth = window.innerWidth + + afterEach(() => { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: originalInnerWidth, + }) + jest.clearAllMocks() + }) + + it("returns true when innerWidth is below 768px", () => { + setupMatchMedia(true) + Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 500 }) + + const { result } = renderHook(() => useIsMobile()) + + expect(result.current).toBe(true) + }) + + it("returns false when innerWidth is 768px or greater", () => { + setupMatchMedia(false) + Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024 }) + + const { result } = renderHook(() => useIsMobile()) + + expect(result.current).toBe(false) + }) + + it("registers matchMedia listener for max-width: 767px", () => { + const { matchMediaMock, mediaQueryList } = setupMatchMedia(false) + Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024 }) + + renderHook(() => useIsMobile()) + + expect(matchMediaMock).toHaveBeenCalledWith("(max-width: 767px)") + expect(mediaQueryList.addEventListener).toHaveBeenCalledWith("change", expect.any(Function)) + }) + + it("updates mobile state when screen resizes and matchMedia change event triggers", () => { + const { triggerChange } = setupMatchMedia(false) + Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024 }) + + const { result } = renderHook(() => useIsMobile()) + expect(result.current).toBe(false) + + // Simulate screen resize to mobile width + act(() => { + Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 600 }) + triggerChange() + }) + + expect(result.current).toBe(true) + }) + + it("removes matchMedia listener on unmount", () => { + const { mediaQueryList } = setupMatchMedia(false) + Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024 }) + + const { unmount } = renderHook(() => useIsMobile()) + + expect(mediaQueryList.addEventListener).toHaveBeenCalledWith("change", expect.any(Function)) + + unmount() + + expect(mediaQueryList.removeEventListener).toHaveBeenCalledWith("change", expect.any(Function)) + }) + + it("handles exact breakpoint boundary at 767px and 768px correctly", () => { + setupMatchMedia(true) + + // 767px is mobile (< 768) + Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 767 }) + const { result: mobileResult } = renderHook(() => useIsMobile()) + expect(mobileResult.current).toBe(true) + + // 768px is desktop (not < 768) + Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 768 }) + const { result: desktopResult } = renderHook(() => useIsMobile()) + expect(desktopResult.current).toBe(false) + }) +}) diff --git a/ui/web/tests/unit/hooks/use-toast.test.tsx b/ui/web/tests/unit/hooks/use-toast.test.tsx new file mode 100644 index 00000000000..bf677812a9f --- /dev/null +++ b/ui/web/tests/unit/hooks/use-toast.test.tsx @@ -0,0 +1,310 @@ +import { act, renderHook } from '@testing-library/react' +import { reducer, toast, useToast } from '@ui/web/hooks/use-toast' + +describe('use-toast', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + // Clear any lingering toasts and pending timers + act(() => { + toast({ title: 'Cleanup' }).dismiss() + jest.runAllTimers() + }) + jest.useRealTimers() + }) + + describe('reducer', () => { + it('handles ADD_TOAST and respects TOAST_LIMIT of 1', () => { + const initialState = { toasts: [] } + const toast1 = { id: '1', title: 'First Toast' } + const toast2 = { id: '2', title: 'Second Toast' } + + const state1 = reducer(initialState, { type: 'ADD_TOAST', toast: toast1 }) + expect(state1.toasts).toEqual([toast1]) + + const state2 = reducer(state1, { type: 'ADD_TOAST', toast: toast2 }) + expect(state2.toasts).toEqual([toast2]) + }) + + it('handles UPDATE_TOAST for existing and non-existing toasts', () => { + const initialState = { + toasts: [ + { id: '1', title: 'Original Title', description: 'Original Desc' }, + ], + } + + // Update matching toast + const updatedState = reducer(initialState, { + type: 'UPDATE_TOAST', + toast: { id: '1', title: 'Updated Title' }, + }) + expect(updatedState.toasts).toEqual([ + { id: '1', title: 'Updated Title', description: 'Original Desc' }, + ]) + + // Update non-matching toast ID + const noChangeState = reducer(initialState, { + type: 'UPDATE_TOAST', + toast: { id: '999', title: 'Non-existent' }, + }) + expect(noChangeState.toasts).toEqual(initialState.toasts) + }) + + it('handles DISMISS_TOAST with specific toastId', () => { + const initialState = { + toasts: [{ id: '1', title: 'Toast 1', open: true }], + } + + const state = reducer(initialState, { + type: 'DISMISS_TOAST', + toastId: '1', + }) + + expect(state.toasts[0].open).toBe(false) + }) + + it('handles DISMISS_TOAST without toastId (dismiss all)', () => { + const initialState = { + toasts: [{ id: '1', title: 'Toast 1', open: true }], + } + + const state = reducer(initialState, { + type: 'DISMISS_TOAST', + toastId: undefined, + }) + + expect(state.toasts.every((t) => t.open === false)).toBe(true) + }) + + it('handles REMOVE_TOAST with specific toastId', () => { + const initialState = { + toasts: [{ id: '1', title: 'Toast 1' }], + } + + const state = reducer(initialState, { + type: 'REMOVE_TOAST', + toastId: '1', + }) + + expect(state.toasts).toEqual([]) + }) + + it('handles REMOVE_TOAST without toastId (remove all)', () => { + const initialState = { + toasts: [{ id: '1', title: 'Toast 1' }], + } + + const state = reducer(initialState, { + type: 'REMOVE_TOAST', + toastId: undefined, + }) + + expect(state.toasts).toEqual([]) + }) + }) + + describe('toast helper function', () => { + it('creates a toast, returns controls, and sets open: true', () => { + const { result } = renderHook(() => useToast()) + + let toastRef: ReturnType | undefined + act(() => { + toastRef = toast({ + title: 'New Toast', + description: 'Toast Description', + }) + }) + + expect(toastRef).toBeDefined() + expect(toastRef?.id).toBeDefined() + expect(result.current.toasts).toHaveLength(1) + expect(result.current.toasts[0]).toMatchObject({ + id: toastRef?.id, + title: 'New Toast', + description: 'Toast Description', + open: true, + }) + }) + + it('allows updating toast content via the returned update function', () => { + const { result } = renderHook(() => useToast()) + + let toastRef: ReturnType | undefined + act(() => { + toastRef = toast({ title: 'Initial Title' }) + }) + + act(() => { + toastRef?.update({ id: toastRef.id, title: 'Updated Title' }) + }) + + expect(result.current.toasts[0].title).toBe('Updated Title') + }) + + it('allows dismissing toast via the returned dismiss function', () => { + const { result } = renderHook(() => useToast()) + + let toastRef: ReturnType | undefined + act(() => { + toastRef = toast({ title: 'To Be Dismissed' }) + }) + + expect(result.current.toasts[0].open).toBe(true) + + act(() => { + toastRef?.dismiss() + }) + + expect(result.current.toasts[0].open).toBe(false) + }) + + it('triggers dismiss when onOpenChange is called with false', () => { + const { result } = renderHook(() => useToast()) + + act(() => { + toast({ title: 'OpenChange Toast' }) + }) + + const currentToast = result.current.toasts[0] + expect(currentToast.open).toBe(true) + + act(() => { + currentToast.onOpenChange?.(false) + }) + + expect(result.current.toasts[0].open).toBe(false) + }) + + it('does not trigger dismiss when onOpenChange is called with true', () => { + const { result } = renderHook(() => useToast()) + + act(() => { + toast({ title: 'OpenChange Toast' }) + }) + + const currentToast = result.current.toasts[0] + + act(() => { + currentToast.onOpenChange?.(true) + }) + + expect(result.current.toasts[0].open).toBe(true) + }) + }) + + describe('useToast hook', () => { + it('provides toast state and helper methods', () => { + const { result } = renderHook(() => useToast()) + + expect(result.current.toasts).toBeDefined() + expect(typeof result.current.toast).toBe('function') + expect(typeof result.current.dismiss).toBe('function') + }) + + it('dismisses a specific toast using hook dismiss(id)', () => { + const { result } = renderHook(() => useToast()) + + let createdId: string | undefined + act(() => { + const t = result.current.toast({ title: 'Toast 1' }) + createdId = t.id + }) + + expect(result.current.toasts[0].open).toBe(true) + + act(() => { + result.current.dismiss(createdId) + }) + + expect(result.current.toasts[0].open).toBe(false) + }) + + it('dismisses all toasts when hook dismiss() is called without id', () => { + const { result } = renderHook(() => useToast()) + + act(() => { + result.current.toast({ title: 'Toast 1' }) + }) + + expect(result.current.toasts[0].open).toBe(true) + + act(() => { + result.current.dismiss() + }) + + expect(result.current.toasts[0].open).toBe(false) + }) + + it('removes toast from state after TOAST_REMOVE_DELAY (1000000ms)', () => { + const { result } = renderHook(() => useToast()) + + act(() => { + result.current.toast({ title: 'Temporary Toast' }) + }) + + expect(result.current.toasts).toHaveLength(1) + + act(() => { + result.current.dismiss() + }) + + expect(result.current.toasts[0].open).toBe(false) + expect(result.current.toasts).toHaveLength(1) + + // Fast-forward removal delay + act(() => { + jest.advanceTimersByTime(1000000) + }) + + expect(result.current.toasts).toHaveLength(0) + }) + + it('prevents duplicate removal timers when dismiss is called twice', () => { + const { result } = renderHook(() => useToast()) + + let createdId: string | undefined + act(() => { + const t = result.current.toast({ title: 'Double Dismiss' }) + createdId = t.id + }) + + act(() => { + result.current.dismiss(createdId) + result.current.dismiss(createdId) + }) + + expect(jest.getTimerCount()).toBe(1) + expect(result.current.toasts[0].open).toBe(false) + + act(() => { + jest.advanceTimersByTime(1000000) + }) + + expect(result.current.toasts).toHaveLength(0) + }) + + it('subscribes and unsubscribes listeners cleanly on mount and unmount', () => { + const hook1 = renderHook(() => useToast()) + const hook2 = renderHook(() => useToast()) + + act(() => { + toast({ title: 'Shared Toast' }) + }) + + expect(hook1.result.current.toasts).toHaveLength(1) + expect(hook2.result.current.toasts).toHaveLength(1) + + // Unmount hook1 + hook1.unmount() + + act(() => { + toast({ title: 'New Toast' }) + }) + + // hook2 receives update, hook1 is unmounted + expect(hook2.result.current.toasts[0].title).toBe('New Toast') + }) + }) +}) diff --git a/ui/web/tests/unit/hooks/useNoteData.test.tsx b/ui/web/tests/unit/hooks/useNoteData.test.tsx new file mode 100644 index 00000000000..884ffe8e828 --- /dev/null +++ b/ui/web/tests/unit/hooks/useNoteData.test.tsx @@ -0,0 +1,277 @@ +import { renderHook } from '@testing-library/react' +import type { NoteViewModel, SearchResult } from '@core/types/domain' +import type { CachedNote } from '@core/types/offline' +import { useNoteData } from '@ui/web/hooks/useNoteData' +import type { useNotesQuery } from '@ui/web/hooks/useNotesQuery' +import type { useNoteSearch } from '@ui/web/hooks/useNoteSearch' + +type AggregatedFtsData = NonNullable['aggregatedFtsData']> + +const makeNote = (id: string, overrides: Partial = {}): NoteViewModel => ({ + id, + title: `Title ${id}`, + description: `Desc ${id}`, + tags: ['tag1'], + user_id: 'user-1', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + ...overrides, +}) + +const makeCachedNote = (id: string, overrides: Partial = {}): CachedNote => ({ + id, + title: `Cached Title ${id}`, + description: `Cached Desc ${id}`, + tags: ['cached'], + status: 'pending', + updatedAt: '2026-01-02T00:00:00Z', + ...overrides, +}) + +const createMockNotesQuery = ( + notes: NoteViewModel[] = [], + totalCount?: number +) => { + return { + data: notes.length || totalCount !== undefined + ? { + pages: [ + { + notes, + totalCount: totalCount ?? notes.length, + hasMore: false, + nextCursor: undefined, + }, + ], + pageParams: [0], + } + : undefined, + isSuccess: true, + isError: false, + isLoading: false, + } as unknown as ReturnType +} + +describe('useNoteData', () => { + it('computes base note list, notesById map, and counts correctly when no overlay is present', () => { + const note1 = makeNote('1') + const note2 = makeNote('2') + const notesQuery = createMockNotesQuery([note1, note2], 100) + const selectedNoteIds = new Set(['1']) + + const { result } = renderHook(() => + useNoteData({ + notesQuery, + offlineOverlay: [], + aggregatedFtsData: undefined, + selectedNoteIds, + }) + ) + + expect(result.current.notes).toEqual([note1, note2]) + expect(result.current.notesById.get('1')).toEqual(note1) + expect(result.current.notesById.get('2')).toEqual(note2) + expect(result.current.notesById.size).toBe(2) + expect(result.current.totalNotes).toBe(100) + expect(result.current.notesDisplayed).toBe(2) + expect(result.current.notesTotal).toBe(100) + expect(result.current.selectedCount).toBe(1) + expect(result.current.mergedFtsData).toBeUndefined() + }) + + it('applies offline overlay (modifications, additions, and deletions)', () => { + const note1 = makeNote('1', { title: 'Server Title 1' }) + const note2 = makeNote('2', { title: 'Server Title 2' }) + const notesQuery = createMockNotesQuery([note1, note2]) + + const offlineOverlay: CachedNote[] = [ + makeCachedNote('1', { title: 'Overlay Title 1', updatedAt: '2026-01-05T00:00:00Z' }), + makeCachedNote('3', { title: 'New Offline Note', updatedAt: '2026-01-06T00:00:00Z' }), + makeCachedNote('2', { deleted: true }), + ] + + const { result } = renderHook(() => + useNoteData({ + notesQuery, + offlineOverlay, + aggregatedFtsData: undefined, + selectedNoteIds: new Set(), + }) + ) + + // note2 should be excluded (deleted), note1 modified, note3 added + expect(result.current.notesById.has('2')).toBe(false) + expect(result.current.notesById.get('1')?.title).toBe('Overlay Title 1') + expect(result.current.notesById.get('3')?.title).toBe('New Offline Note') + expect(result.current.notesDisplayed).toBe(2) + }) + + it('falls back to notes.length for totalNotes when totalCount is missing or pages are empty', () => { + const note1 = makeNote('1') + const queryWithoutTotalCount = { + data: { + pages: [{ notes: [note1], totalCount: undefined as unknown as number, hasMore: false }], + pageParams: [0], + }, + } as unknown as ReturnType + + const { result: result1 } = renderHook(() => + useNoteData({ + notesQuery: queryWithoutTotalCount, + offlineOverlay: [], + aggregatedFtsData: undefined, + selectedNoteIds: new Set(), + }) + ) + + expect(result1.current.totalNotes).toBe(1) + expect(result1.current.notesTotal).toBe(1) + + const emptyQuery = createMockNotesQuery([]) + const { result: result2 } = renderHook(() => + useNoteData({ + notesQuery: emptyQuery, + offlineOverlay: [], + aggregatedFtsData: undefined, + selectedNoteIds: new Set(), + }) + ) + + expect(result2.current.totalNotes).toBe(0) + expect(result2.current.notesTotal).toBe(0) + }) + + it('resolves search results by merging latest fields from notesById', () => { + const note1 = makeNote('1', { title: 'Updated Title', updated_at: '2026-01-10T00:00:00Z' }) + const notesQuery = createMockNotesQuery([note1]) + + const { result } = renderHook(() => + useNoteData({ + notesQuery, + offlineOverlay: [], + aggregatedFtsData: undefined, + selectedNoteIds: new Set(), + }) + ) + + const searchResult1: SearchResult = { + id: '1', + title: 'Old Title', + description: 'Search Desc', + tags: ['tag1'], + user_id: 'user-1', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + headline: 'Old Title', + rank: 0.8, + } + + const resolved = result.current.resolveSearchResult(searchResult1) + expect(resolved.title).toBe('Updated Title') + expect(resolved.updated_at).toBe('2026-01-10T00:00:00Z') + + // Searching for non-existent note returns searchResult unchanged + const unknownSearchResult: SearchResult = { + id: '99', + title: 'Unknown', + description: 'Unknown Desc', + tags: [], + user_id: 'user-1', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + rank: 0, + headline: null, + } + + const resolvedUnknown = result.current.resolveSearchResult(unknownSearchResult) + expect(resolvedUnknown).toEqual(unknownSearchResult) + }) + + it('merges aggregated FTS search results correctly', () => { + const note1 = makeNote('1', { title: 'Fresh Title', updated_at: '2026-01-10T00:00:00Z' }) + const notesQuery = createMockNotesQuery([note1]) + + const ftsData: AggregatedFtsData = { + results: [ + { + id: '1', + title: 'Stale Title', + description: 'Desc', + tags: ['fts'], + user_id: 'user-1', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + rank: 0.95, + headline: null, + }, + ], + total: 1, + method: 'fts', + executionTime: 12, + query: 'test', + } + + const { result, rerender } = renderHook( + (props) => useNoteData(props), + { + initialProps: { + notesQuery, + offlineOverlay: [], + aggregatedFtsData: ftsData, + selectedNoteIds: new Set(), + }, + } + ) + + expect(result.current.mergedFtsData?.results[0].title).toBe('Fresh Title') + + // Test with empty aggregated FTS results + const emptyFtsData: AggregatedFtsData = { + results: [], + total: 0, + method: 'fts', + executionTime: 0, + query: '', + } + + rerender({ + notesQuery, + offlineOverlay: [], + aggregatedFtsData: emptyFtsData, + selectedNoteIds: new Set(), + }) + + expect(result.current.mergedFtsData).toBe(emptyFtsData) + }) + + it('maintains notesRef in sync with notes state across rerenders', () => { + const note1 = makeNote('1') + const initialQuery = createMockNotesQuery([note1]) + + const { result, rerender } = renderHook( + (props) => useNoteData(props), + { + initialProps: { + notesQuery: initialQuery, + offlineOverlay: [], + aggregatedFtsData: undefined, + selectedNoteIds: new Set(), + }, + } + ) + + expect(result.current.notesRef.current).toEqual([note1]) + + const note2 = makeNote('2') + const updatedQuery = createMockNotesQuery([note1, note2]) + + rerender({ + notesQuery: updatedQuery, + offlineOverlay: [], + aggregatedFtsData: undefined, + selectedNoteIds: new Set(), + }) + + expect(result.current.notesRef.current).toEqual([note1, note2]) + }) +}) diff --git a/ui/web/tests/unit/hooks/useNotesQuery.test.tsx b/ui/web/tests/unit/hooks/useNotesQuery.test.tsx new file mode 100644 index 00000000000..7e5b3da3045 --- /dev/null +++ b/ui/web/tests/unit/hooks/useNotesQuery.test.tsx @@ -0,0 +1,424 @@ +import React from 'react' +import { renderHook, waitFor, act } from '@testing-library/react' +import { QueryClient, QueryClientProvider, InfiniteData } from '@tanstack/react-query' +import type { Tables } from '@/supabase/types' +import { + useNotesQuery, + useFlattenedNotes, + useSearchNotes, +} from '@ui/web/hooks/useNotesQuery' +import { useSupabase } from '@ui/web/providers/SupabaseProvider' + +type Note = Tables<'notes'> + +const mockNoteService = { + getNotes: jest.fn(), +} + +const mockSearchService = { + searchNotes: jest.fn(), +} + +const mockSupabase = {} + +jest.mock('@ui/web/providers/SupabaseProvider', () => ({ + useSupabase: jest.fn(), +})) + +jest.mock('@core/services/notes', () => ({ + NoteService: jest.fn().mockImplementation(() => mockNoteService), +})) + +jest.mock('@core/services/search', () => ({ + SearchService: jest.fn().mockImplementation(() => mockSearchService), +})) + +const makeNote = (overrides: Partial = {}): Note => ({ + id: 'note-1', + user_id: 'user-1', + title: 'Test Note', + description: 'Test Description', + tags: ['work'], + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + ...overrides, +}) + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children} + } +} + +describe('useNotesQuery', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(useSupabase).mockReturnValue({ + supabase: mockSupabase as never, + user: null, + loading: false, + }) + }) + + it('fetches initial page of notes with default options', async () => { + const pageData = { + notes: [makeNote({ id: 'note-1' }), makeNote({ id: 'note-2' })], + nextCursor: 1, + totalCount: 2, + hasMore: true, + } + mockNoteService.getNotes.mockResolvedValue(pageData) + + const { result } = renderHook(() => useNotesQuery({ userId: 'user-1' }), { + wrapper: createWrapper(), + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(mockNoteService.getNotes).toHaveBeenCalledWith('user-1', { + page: 0, + pageSize: 50, + tag: null, + searchQuery: '', + }) + + expect(result.current.data?.pages[0]).toEqual(pageData) + }) + + it('passes search query and tag filter options to NoteService', async () => { + const pageData = { + notes: [makeNote({ id: 'note-filtered' })], + nextCursor: undefined, + totalCount: 1, + hasMore: false, + } + mockNoteService.getNotes.mockResolvedValue(pageData) + + const { result } = renderHook( + () => + useNotesQuery({ + userId: 'user-1', + searchQuery: 'react', + selectedTag: 'frontend', + }), + { wrapper: createWrapper() } + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(mockNoteService.getNotes).toHaveBeenCalledWith('user-1', { + page: 0, + pageSize: 50, + tag: 'frontend', + searchQuery: 'react', + }) + }) + + it('does not execute query when enabled is false', () => { + const { result } = renderHook( + () => useNotesQuery({ userId: 'user-1', enabled: false }), + { wrapper: createWrapper() } + ) + + expect(mockNoteService.getNotes).not.toHaveBeenCalled() + expect(result.current.isFetching).toBe(false) + }) + + it('supports infinite pagination via fetchNextPage', async () => { + const page0 = { + notes: [makeNote({ id: 'page-0-note' })], + nextCursor: 1, + totalCount: 2, + hasMore: true, + } + const page1 = { + notes: [makeNote({ id: 'page-1-note' })], + nextCursor: undefined, + totalCount: 2, + hasMore: false, + } + + mockNoteService.getNotes.mockImplementation((_userId, options) => { + if (options.page === 0) return Promise.resolve(page0) + if (options.page === 1) return Promise.resolve(page1) + return Promise.reject(new Error('Invalid page')) + }) + + const { result } = renderHook(() => useNotesQuery({ userId: 'user-1' }), { + wrapper: createWrapper(), + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.hasNextPage).toBe(true) + + act(() => { + result.current.fetchNextPage() + }) + + await waitFor(() => expect(result.current.data?.pages).toHaveLength(2)) + + expect(mockNoteService.getNotes).toHaveBeenCalledWith('user-1', expect.objectContaining({ page: 1 })) + expect(result.current.data?.pages[1]).toEqual(page1) + expect(result.current.hasNextPage).toBe(false) + }) + + it('handles error states when getNotes fails', async () => { + const error = new Error('Database connection failed') + mockNoteService.getNotes.mockRejectedValue(error) + + const { result } = renderHook(() => useNotesQuery({ userId: 'user-1' }), { + wrapper: createWrapper(), + }) + + await waitFor(() => expect(result.current.isError).toBe(true)) + + expect(result.current.error).toEqual(error) + }) + + it('handles empty notes result correctly', async () => { + const emptyPage = { + notes: [], + nextCursor: undefined, + totalCount: 0, + hasMore: false, + } + mockNoteService.getNotes.mockResolvedValue(emptyPage) + + const { result } = renderHook(() => useNotesQuery({ userId: 'user-1' }), { + wrapper: createWrapper(), + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data?.pages[0].notes).toEqual([]) + expect(result.current.hasNextPage).toBe(false) + }) + + it('refetches when query parameters change', async () => { + mockNoteService.getNotes.mockResolvedValue({ + notes: [], + nextCursor: undefined, + totalCount: 0, + hasMore: false, + }) + + const { rerender } = renderHook( + (props: Parameters[0]) => useNotesQuery(props), + { + initialProps: { userId: 'user-1', searchQuery: 'initial', selectedTag: null as string | null }, + wrapper: createWrapper(), + } + ) + + await waitFor(() => + expect(mockNoteService.getNotes).toHaveBeenCalledWith('user-1', expect.objectContaining({ searchQuery: 'initial' })) + ) + + rerender({ userId: 'user-1', searchQuery: 'updated', selectedTag: 'newTag' }) + + await waitFor(() => + expect(mockNoteService.getNotes).toHaveBeenCalledWith('user-1', expect.objectContaining({ searchQuery: 'updated', tag: 'newTag' })) + ) + }) +}) + +describe('useFlattenedNotes', () => { + it('returns empty array when queryResult has no data or empty pages', () => { + expect(useFlattenedNotes({})).toEqual([]) + expect(useFlattenedNotes({ data: undefined })).toEqual([]) + expect(useFlattenedNotes({ data: { pages: [], pageParams: [] } })).toEqual([]) + }) + + it('flattens notes across multiple pages into a single array', () => { + const note1 = makeNote({ id: '1' }) + const note2 = makeNote({ id: '2' }) + const note3 = makeNote({ id: '3' }) + + const queryResult: { data: InfiniteData<{ notes: Note[]; totalCount: number; hasMore: boolean }> } = { + data: { + pages: [ + { notes: [note1, note2], totalCount: 3, hasMore: true }, + { notes: [note3], totalCount: 3, hasMore: false }, + ], + pageParams: [0, 1], + }, + } + + expect(useFlattenedNotes(queryResult)).toEqual([note1, note2, note3]) + }) +}) + +describe('useSearchNotes', () => { + const originalLanguage = navigator.language + + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(useSupabase).mockReturnValue({ + supabase: mockSupabase as never, + user: null, + loading: false, + }) + }) + + afterEach(() => { + Object.defineProperty(navigator, 'language', { + value: originalLanguage, + configurable: true, + }) + }) + + it('does not execute search if query length is less than MIN_QUERY_LENGTH (3 characters)', () => { + const { result } = renderHook(() => useSearchNotes('ab', 'user-1'), { + wrapper: createWrapper(), + }) + + expect(mockSearchService.searchNotes).not.toHaveBeenCalled() + expect(result.current.fetchStatus).toBe('idle') + }) + + it('does not execute search if query is whitespace-only', () => { + const { result } = renderHook(() => useSearchNotes(' ', 'user-1'), { + wrapper: createWrapper(), + }) + + expect(mockSearchService.searchNotes).not.toHaveBeenCalled() + expect(result.current.fetchStatus).toBe('idle') + }) + + it('does not execute search if userId is missing', () => { + const { result } = renderHook(() => useSearchNotes('valid query', undefined), { + wrapper: createWrapper(), + }) + + expect(mockSearchService.searchNotes).not.toHaveBeenCalled() + expect(result.current.fetchStatus).toBe('idle') + }) + + it('does not execute search when enabled is false', () => { + const { result } = renderHook( + () => useSearchNotes('valid query', 'user-1', { enabled: false }), + { wrapper: createWrapper() } + ) + + expect(mockSearchService.searchNotes).not.toHaveBeenCalled() + expect(result.current.fetchStatus).toBe('idle') + }) + + it('executes search with default options when query is valid and debounced', async () => { + const searchResult = { + notes: [makeNote({ id: 'search-1' })], + totalCount: 1, + hasMore: false, + } + mockSearchService.searchNotes.mockResolvedValue(searchResult) + + const { result } = renderHook(() => useSearchNotes('testing', 'user-1'), { + wrapper: createWrapper(), + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(mockSearchService.searchNotes).toHaveBeenCalledWith('user-1', 'testing', { + language: expect.any(String), + minRank: 0.01, + limit: 50, + offset: 0, + tag: undefined, + }) + + expect(result.current.data).toEqual({ + ...searchResult, + query: 'testing', + executionTime: expect.any(Number), + }) + }) + + it('passes custom search options correctly', async () => { + const searchResult = { notes: [], totalCount: 0, hasMore: false } + mockSearchService.searchNotes.mockResolvedValue(searchResult) + + const { result } = renderHook( + () => + useSearchNotes('custom query', 'user-1', { + language: 'uk', + minRank: 0.1, + limit: 20, + offset: 10, + selectedTag: 'projects', + }), + { wrapper: createWrapper() } + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(mockSearchService.searchNotes).toHaveBeenCalledWith('user-1', 'custom query', { + language: 'uk', + minRank: 0.1, + limit: 20, + offset: 10, + tag: 'projects', + }) + }) + + it('detects language from browser locale correctly', async () => { + Object.defineProperty(navigator, 'language', { + value: 'en-US', + configurable: true, + }) + + mockSearchService.searchNotes.mockResolvedValue({ notes: [], totalCount: 0, hasMore: false }) + + const { result } = renderHook(() => useSearchNotes('language test', 'user-1'), { + wrapper: createWrapper(), + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(mockSearchService.searchNotes).toHaveBeenCalledWith( + 'user-1', + 'language test', + expect.objectContaining({ language: 'en' }) + ) + }) + + it('defaults language to ru for unsupported browser locale', async () => { + Object.defineProperty(navigator, 'language', { + value: 'fr-FR', + configurable: true, + }) + + mockSearchService.searchNotes.mockResolvedValue({ notes: [], totalCount: 0, hasMore: false }) + + const { result } = renderHook(() => useSearchNotes('locale test', 'user-1'), { + wrapper: createWrapper(), + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(mockSearchService.searchNotes).toHaveBeenCalledWith( + 'user-1', + 'locale test', + expect.objectContaining({ language: 'ru' }) + ) + }) + + it('handles search errors correctly', async () => { + const searchError = new Error('Search index unavailable') + mockSearchService.searchNotes.mockRejectedValue(searchError) + + const { result } = renderHook(() => useSearchNotes('error query', 'user-1'), { + wrapper: createWrapper(), + }) + + await waitFor(() => expect(result.current.isError).toBe(true)) + + expect(result.current.error).toEqual(searchError) + }) +}) diff --git a/ui/web/tests/unit/hooks/useTagSuggestions.test.ts b/ui/web/tests/unit/hooks/useTagSuggestions.test.ts new file mode 100644 index 00000000000..8771afb707f --- /dev/null +++ b/ui/web/tests/unit/hooks/useTagSuggestions.test.ts @@ -0,0 +1,167 @@ +import { renderHook } from '@testing-library/react' +import { useTagSuggestions } from '@ui/web/hooks/useTagSuggestions' + +describe('useTagSuggestions', () => { + const defaultTags = ['react', 'react-dom', 'react-router', 'redux', 'refactor', 'rust'] + + it('returns empty array when query length is less than default minChars (3)', () => { + const { result } = renderHook(() => + useTagSuggestions({ + allTags: defaultTags, + selectedTags: [], + query: 're', + }) + ) + + expect(result.current).toEqual([]) + }) + + it('returns empty array for empty query string', () => { + const { result } = renderHook(() => + useTagSuggestions({ + allTags: defaultTags, + selectedTags: [], + query: '', + }) + ) + + expect(result.current).toEqual([]) + }) + + it('returns matching tag suggestions starting with the query when minChars condition is met', () => { + const { result } = renderHook(() => + useTagSuggestions({ + allTags: defaultTags, + selectedTags: [], + query: 'rea', + }) + ) + + expect(result.current).toEqual(['react', 'react-dom', 'react-router']) + }) + + it('respects custom minChars option', () => { + const { result } = renderHook(() => + useTagSuggestions({ + allTags: defaultTags, + selectedTags: [], + query: 'ru', + minChars: 2, + }) + ) + + expect(result.current).toEqual(['rust']) + }) + + it('filters out already selected tags from suggestions', () => { + const { result } = renderHook(() => + useTagSuggestions({ + allTags: defaultTags, + selectedTags: ['react'], + query: 'rea', + }) + ) + + expect(result.current).toEqual(['react-dom', 'react-router']) + }) + + it('sorts suggestions alphabetically (localeCompare)', () => { + const unorderedTags = ['react-router', 'react', 'react-dom', 'react-native'] + const { result } = renderHook(() => + useTagSuggestions({ + allTags: unorderedTags, + selectedTags: [], + query: 'rea', + limit: 10, + }) + ) + + expect(result.current).toEqual(['react', 'react-dom', 'react-native', 'react-router']) + }) + + it('limits the number of returned suggestions to the specified limit', () => { + const { result } = renderHook(() => + useTagSuggestions({ + allTags: defaultTags, + selectedTags: [], + query: 'rea', + limit: 2, + }) + ) + + expect(result.current).toHaveLength(2) + expect(result.current).toEqual(['react', 'react-dom']) + }) + + it('returns empty array when all matching tags are in selectedTags', () => { + const { result } = renderHook(() => + useTagSuggestions({ + allTags: ['react', 'react-dom'], + selectedTags: ['react', 'react-dom'], + query: 'rea', + }) + ) + + expect(result.current).toEqual([]) + }) + + it('returns empty array when allTags is empty', () => { + const { result } = renderHook(() => + useTagSuggestions({ + allTags: [], + selectedTags: [], + query: 'react', + }) + ) + + expect(result.current).toEqual([]) + }) + + it('memoizes the output when props do not change', () => { + const props = { + allTags: defaultTags, + selectedTags: [], + query: 'rea', + } + const { result, rerender } = renderHook((p) => useTagSuggestions(p), { + initialProps: props, + }) + + const firstResult = result.current + rerender(props) + const secondResult = result.current + + expect(firstResult).toBe(secondResult) + }) + + it('updates suggestions when props (like query or selectedTags) change', () => { + const { result, rerender } = renderHook( + (props) => useTagSuggestions(props), + { + initialProps: { + allTags: defaultTags, + selectedTags: [] as string[], + query: 'rea', + }, + } + ) + + expect(result.current).toEqual(['react', 'react-dom', 'react-router']) + + rerender({ + allTags: defaultTags, + selectedTags: ['react'], + query: 'rea', + }) + + expect(result.current).toEqual(['react-dom', 'react-router']) + + rerender({ + allTags: defaultTags, + selectedTags: [], + query: 'red', + }) + + expect(result.current).toEqual(['redux']) + }) +}) diff --git a/ui/web/tests/unit/lib/editor.test.ts b/ui/web/tests/unit/lib/editor.test.ts new file mode 100644 index 00000000000..218ff691907 --- /dev/null +++ b/ui/web/tests/unit/lib/editor.test.ts @@ -0,0 +1,115 @@ +import type { Editor } from "@tiptap/react" +import { SmartPasteService } from "@core/services/smartPaste" +import { applySelectionAsMarkdown } from "@ui/web/lib/editor" + +function createMockEditor(from = 0, to = 10, selectedText = "# Heading 1") { + const runMock = jest.fn() + const insertContentMock = jest.fn().mockReturnValue({ run: runMock }) + const deleteRangeMock = jest.fn().mockReturnValue({ insertContent: insertContentMock }) + const focusMock = jest.fn().mockReturnValue({ deleteRange: deleteRangeMock }) + const chainMock = jest.fn().mockReturnValue({ focus: focusMock }) + + const textBetweenMock = jest.fn().mockReturnValue(selectedText) + + const editor = { + state: { + selection: { from, to }, + doc: { + textBetween: textBetweenMock, + }, + }, + chain: chainMock, + } as unknown as Editor + + return { + editor, + chainMock, + focusMock, + deleteRangeMock, + insertContentMock, + runMock, + textBetweenMock, + } +} + +describe("applySelectionAsMarkdown", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("does nothing when selection range is empty (from === to)", () => { + const { editor, chainMock, textBetweenMock } = createMockEditor(5, 5) + const onContentChange = jest.fn() + const resolvePasteSpy = jest.spyOn(SmartPasteService, "resolvePaste") + + applySelectionAsMarkdown(editor, onContentChange) + + expect(textBetweenMock).not.toHaveBeenCalled() + expect(resolvePasteSpy).not.toHaveBeenCalled() + expect(chainMock).not.toHaveBeenCalled() + expect(onContentChange).not.toHaveBeenCalled() + }) + + it("converts selected markdown text to HTML, replaces selection, and triggers onContentChange", () => { + const { + editor, + chainMock, + focusMock, + deleteRangeMock, + insertContentMock, + runMock, + textBetweenMock, + } = createMockEditor(0, 14, "**Bold Text**") + const onContentChange = jest.fn() + const resolvePasteSpy = jest.spyOn(SmartPasteService, "resolvePaste").mockReturnValueOnce({ + html: "Bold Text", + type: "markdown", + } as ReturnType) + + applySelectionAsMarkdown(editor, onContentChange) + + expect(textBetweenMock).toHaveBeenCalledWith(0, 14, "\n\n") + expect(resolvePasteSpy).toHaveBeenCalledWith( + { html: null, text: "**Bold Text**", types: ["text/plain"] }, + undefined, + "markdown" + ) + expect(chainMock).toHaveBeenCalledTimes(1) + expect(focusMock).toHaveBeenCalledTimes(1) + expect(deleteRangeMock).toHaveBeenCalledWith({ from: 0, to: 14 }) + expect(insertContentMock).toHaveBeenCalledWith("Bold Text") + expect(runMock).toHaveBeenCalledTimes(1) + expect(onContentChange).toHaveBeenCalledTimes(1) + resolvePasteSpy.mockRestore() + }) + + it("works safely when onContentChange callback is omitted", () => { + const { editor, runMock, insertContentMock } = createMockEditor(0, 10, "*Italic Text*") + const resolvePasteSpy = jest.spyOn(SmartPasteService, "resolvePaste").mockReturnValueOnce({ + html: "Italic Text", + type: "markdown", + } as ReturnType) + + expect(() => applySelectionAsMarkdown(editor)).not.toThrow() + expect(insertContentMock).toHaveBeenCalledWith("Italic Text") + expect(runMock).toHaveBeenCalledTimes(1) + resolvePasteSpy.mockRestore() + }) + + it("bails out when SmartPasteService returns empty HTML result", () => { + const { editor, chainMock } = createMockEditor(0, 5, "plain text") + const onContentChange = jest.fn() + + jest.spyOn(SmartPasteService, "resolvePaste").mockReturnValueOnce({ + html: "", + type: "markdown", + warnings: [], + detection: { type: "markdown", confidence: 1, reasons: [], warnings: [] }, + }) + + applySelectionAsMarkdown(editor, onContentChange) + + expect(chainMock).not.toHaveBeenCalled() + expect(onContentChange).not.toHaveBeenCalled() + }) +})