diff --git a/.cursorrules/testing.md b/.cursorrules/testing.md index 3ae10dd64..a14b65e88 100644 --- a/.cursorrules/testing.md +++ b/.cursorrules/testing.md @@ -83,6 +83,13 @@ const button = container.querySelector('.add-button'); Match existing test patterns in the repository. Review similar tests before writing new ones. +Use `act` from `react`, not `react-testing-library`, to avoid warnings about updates not wrapped in `act`. +Example: + +``` +import { act } from "react"; +``` + ## Summary - Run `yarn test:web`, `yarn test:backend`, or `yarn test:core` based on changes diff --git a/eslint.config.mjs b/eslint.config.mjs index 7ba626706..e15322a1c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -89,6 +89,8 @@ export default [ "jest/no-identical-title": "error", "jest/prefer-to-have-length": "warn", "jest/valid-expect": "error", + "@typescript-eslint/unbound-method": "off", + "jest/unbound-method": "error", }, }, // Warn on console.log in packages/web to avoid leaking secure info diff --git a/packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-sync-watch-data.ts b/packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-sync-watch-data.ts index 9184b145d..2348bfff5 100644 --- a/packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-sync-watch-data.ts +++ b/packages/scripts/src/migrations/2025.10.13T14.22.21.migrate-sync-watch-data.ts @@ -45,8 +45,6 @@ export default class Migration implements RunnableMigration { { batchSize: MONGO_BATCH_SIZE }, ); - let migratedCount = 0; - for await (const syncDoc of cursor) { if ((syncDoc?.google?.events?.length ?? 0) < 1) continue; @@ -119,17 +117,11 @@ export default class Migration implements RunnableMigration { ]); if (watchDocuments.length > 0) { - const result = await mongoService.watch.insertMany(watchDocuments, { + await mongoService.watch.insertMany(watchDocuments, { ordered: false, }); - - migratedCount += result.insertedCount; } } - - logger.info( - `Migrated ${migratedCount} events watch channels to watch collection`, - ); } async down(params: MigrationParams): Promise { diff --git a/packages/scripts/src/migrations/2025.10.18T20.01.14.migrate-events-to-new-events-collection.ts b/packages/scripts/src/migrations/2025.10.18T20.01.14.migrate-events-to-new-events-collection.ts index 93dfecd27..ddea5cb4d 100644 --- a/packages/scripts/src/migrations/2025.10.18T20.01.14.migrate-events-to-new-events-collection.ts +++ b/packages/scripts/src/migrations/2025.10.18T20.01.14.migrate-events-to-new-events-collection.ts @@ -150,11 +150,7 @@ export default class Migration implements RunnableMigration { } if (bulkInsert.batches.length > 0) { - const result = await bulkInsert.execute(); - - logger.info( - `Migrated ${result.insertedCount} events into ${collectionName}`, - ); + await bulkInsert.execute(); } else { logger.info(`No events to migrate into ${collectionName}`); } diff --git a/packages/web/src/auth/context/UserProvider.test.tsx b/packages/web/src/auth/context/UserProvider.test.tsx index 6837f0d23..28b4d4714 100644 --- a/packages/web/src/auth/context/UserProvider.test.tsx +++ b/packages/web/src/auth/context/UserProvider.test.tsx @@ -1,4 +1,5 @@ import { rest } from "msw"; +import { type PostHog } from "posthog-js"; import { usePostHog } from "posthog-js/react"; import { act, isValidElement } from "react"; import { toast } from "react-toastify"; @@ -12,34 +13,45 @@ import { ENV_WEB } from "@web/common/constants/env.constants"; import * as authStateUtil from "@web/common/utils/storage/auth-state.util"; import { SessionExpiredToast } from "@web/common/utils/toast/session-expired.toast"; -// Mock PostHog jest.mock("posthog-js/react"); -const mockUsePostHog = usePostHog as jest.MockedFunction; - -const mockToastError = toast.error as jest.MockedFunction; - -// Mock auth state util -jest.mock("@web/common/utils/storage/auth-state.util", () => ({ - ...jest.requireActual("@web/common/utils/storage/auth-state.util"), - hasUserEverAuthenticated: jest.fn(), -})); -const mockHasUserEverAuthenticated = - authStateUtil.hasUserEverAuthenticated as jest.MockedFunction< - typeof authStateUtil.hasUserEverAuthenticated - >; +const mockUsePostHog = jest.mocked(usePostHog); +const mockToastError = jest.mocked(toast.error); + +jest.mock("@web/common/utils/storage/auth-state.util", () => { + const actual: typeof authStateUtil = jest.requireActual( + "@web/common/utils/storage/auth-state.util", + ); + return { + ...actual, + hasUserEverAuthenticated: jest.fn(), + }; +}); +const mockHasUserEverAuthenticated = jest.mocked( + authStateUtil.hasUserEverAuthenticated, +); -// Mock AbsoluteOverflowLoader jest.mock("@web/components/AbsoluteOverflowLoader", () => ({ AbsoluteOverflowLoader: () =>
Loading...
, })); -describe("UserProvider", () => { - const mockIdentify = jest.fn(); +const mockIdentify = jest.fn(); +function mockPostHogEnabled(overrides?: Partial): void { + mockUsePostHog.mockReturnValue({ + identify: mockIdentify, + ...overrides, + } as unknown as PostHog); +} + +function mockPostHogDisabled(): void { + mockUsePostHog.mockReturnValue(undefined as unknown as PostHog); +} + +describe("UserProvider", () => { beforeEach(() => { jest.clearAllMocks(); - // Default to authenticated so existing tests continue to work mockHasUserEverAuthenticated.mockReturnValue(true); + mockPostHogEnabled(); }); describe("PostHog Integration", () => { @@ -47,18 +59,12 @@ describe("UserProvider", () => { const testUserId = "test-user-123"; const testEmail = "test@example.com"; - // Mock PostHog as enabled - mockUsePostHog.mockReturnValue({ - identify: mockIdentify, - } as any); - render(
Test Child
, ); - // Wait for async data fetch and PostHog identify to be called await waitFor(() => { expect(screen.getByText("Test Child")).toBeInTheDocument(); }); @@ -68,13 +74,11 @@ describe("UserProvider", () => { userId: testUserId, }); - // Verify it was called exactly once expect(mockIdentify).toHaveBeenCalledTimes(1); }); it("should NOT call posthog.identify when PostHog is disabled", async () => { - // Mock PostHog as disabled (returns undefined/null) - mockUsePostHog.mockReturnValue(undefined as any); + mockPostHogDisabled(); render( @@ -86,7 +90,6 @@ describe("UserProvider", () => { expect(screen.getByText("Test Child")).toBeInTheDocument(); }); - // Verify identify was never called expect(mockIdentify).not.toHaveBeenCalled(); }); @@ -100,11 +103,6 @@ describe("UserProvider", () => { }), ); - // Mock PostHog as enabled - mockUsePostHog.mockReturnValue({ - identify: mockIdentify, - } as any); - render(
Test Child
@@ -115,7 +113,6 @@ describe("UserProvider", () => { expect(screen.getByText("Test Child")).toBeInTheDocument(); }); - // Verify identify was not called because email is missing expect(mockIdentify).not.toHaveBeenCalled(); }); @@ -130,11 +127,6 @@ describe("UserProvider", () => { }), ); - // Mock PostHog as enabled - mockUsePostHog.mockReturnValue({ - identify: mockIdentify, - } as any); - render(
Test Child
@@ -151,12 +143,10 @@ describe("UserProvider", () => { }); it("should handle posthog.identify not being a function gracefully", async () => { - // Mock PostHog with identify not being a function - mockUsePostHog.mockReturnValue({ - identify: null, - } as any); + mockPostHogEnabled({ + identify: null as unknown as PostHog["identify"], + }); - // Should not throw an error expect(() => { render( @@ -169,7 +159,6 @@ describe("UserProvider", () => { expect(screen.getByText("Test Child")).toBeInTheDocument(); }); - // Verify identify was not called expect(mockIdentify).not.toHaveBeenCalled(); }); @@ -190,26 +179,27 @@ describe("UserProvider", () => { }), ); - mockUsePostHog.mockReturnValue({ - identify: mockIdentify, - } as any); - render(
Test Child Content
, ); - // Initially should show loading expect(screen.getByText("Loading...")).toBeInTheDocument(); - // After data loads, should show children await waitFor(() => { expect(screen.getByText("Test Child Content")).toBeInTheDocument(); }); }); it("shows a login toast when profile fetch returns unauthorized", async () => { + const assignMock = jest.fn(); + const originalLocation = window.location; + Object.defineProperty(window, "location", { + value: { ...originalLocation, assign: assignMock }, + configurable: true, + }); + const getProfileSpy = jest.spyOn(UserApi, "getProfile"); server.use( rest.get(`${ENV_WEB.API_BASEURL}/user/profile`, (_req, res, ctx) => { @@ -217,10 +207,6 @@ describe("UserProvider", () => { }), ); - mockUsePostHog.mockReturnValue({ - identify: mockIdentify, - } as any); - render(
Test Child
@@ -231,14 +217,15 @@ describe("UserProvider", () => { await act(async () => { try { await getProfileSpy.mock.results[0].value; - } catch (e) { - // Ignore error + } catch { + // expected — profile fetch rejects on 401 } }); expect(mockToastError).toHaveBeenCalled(); - const latestToastCall = mockToastError.mock.calls.at(-1); - expect(latestToastCall?.[1]).toEqual( + const latestToastCall = + mockToastError.mock.calls[mockToastError.mock.calls.length - 1]; + expect(latestToastCall[1]).toEqual( expect.objectContaining({ toastId: "session-expired-api", autoClose: false, @@ -247,17 +234,20 @@ describe("UserProvider", () => { }), ); - const toastContent = latestToastCall?.[0]; + const toastContent = latestToastCall[0]; expect(isValidElement(toastContent)).toBe(true); - if (isValidElement(toastContent)) { + if (isValidElement<{ toastId: string }>(toastContent)) { expect(toastContent.type).toBe(SessionExpiredToast); expect(toastContent.props.toastId).toBe("session-expired-api"); } - // Should not call identify expect(mockIdentify).not.toHaveBeenCalled(); getProfileSpy.mockRestore(); + Object.defineProperty(window, "location", { + value: originalLocation, + configurable: true, + }); }); }); @@ -265,9 +255,6 @@ describe("UserProvider", () => { it("should NOT call getProfile when user has never authenticated", async () => { mockHasUserEverAuthenticated.mockReturnValue(false); const getProfileSpy = jest.spyOn(UserApi, "getProfile"); - mockUsePostHog.mockReturnValue({ - identify: mockIdentify, - } as any); render( @@ -286,9 +273,6 @@ describe("UserProvider", () => { it("should call getProfile when user has authenticated with Google", async () => { mockHasUserEverAuthenticated.mockReturnValue(true); const getProfileSpy = jest.spyOn(UserApi, "getProfile"); - mockUsePostHog.mockReturnValue({ - identify: mockIdentify, - } as any); render( diff --git a/packages/web/src/common/hooks/useIsMobile.test.ts b/packages/web/src/common/hooks/useIsMobile.test.ts index 5762e5860..980651efa 100644 --- a/packages/web/src/common/hooks/useIsMobile.test.ts +++ b/packages/web/src/common/hooks/useIsMobile.test.ts @@ -1,3 +1,4 @@ +import { act } from "react"; import { renderHook } from "@testing-library/react"; import { useIsMobile } from "./useIsMobile"; @@ -60,19 +61,21 @@ describe("useIsMobile", () => { // Simulate viewport change to mobile mockMediaQuery.matches = true; - if (changeHandler) { - changeHandler(); - } - rerender(); + act(() => { + if (changeHandler) { + changeHandler(); + } + }); expect(result.current).toBe(true); // Simulate viewport change back to desktop mockMediaQuery.matches = false; - if (changeHandler) { - changeHandler(); - } - rerender(); + act(() => { + if (changeHandler) { + changeHandler(); + } + }); expect(result.current).toBe(false); }); diff --git a/packages/web/src/common/storage/adapter/adapter.test.ts b/packages/web/src/common/storage/adapter/adapter.test.ts index de4bc06ca..69522b7cb 100644 --- a/packages/web/src/common/storage/adapter/adapter.test.ts +++ b/packages/web/src/common/storage/adapter/adapter.test.ts @@ -11,13 +11,20 @@ import { import { IndexedDBAdapter } from "@web/common/storage/adapter/indexeddb.adapter"; describe("storage adapter index", () => { + let consoleLogSpy: jest.SpyInstance; + let consoleWarnSpy: jest.SpyInstance; + beforeEach(() => { + consoleLogSpy = jest.spyOn(console, "log").mockImplementation(); + consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); resetStorage(); jest.clearAllMocks(); }); afterEach(() => { resetStorage(); + consoleLogSpy.mockRestore(); + consoleWarnSpy.mockRestore(); }); describe("getStorageAdapter", () => { diff --git a/packages/web/src/common/storage/adapter/indexeddb.adapter.test.ts b/packages/web/src/common/storage/adapter/indexeddb.adapter.test.ts index 9ba79d46a..257343349 100644 --- a/packages/web/src/common/storage/adapter/indexeddb.adapter.test.ts +++ b/packages/web/src/common/storage/adapter/indexeddb.adapter.test.ts @@ -12,14 +12,21 @@ import { LegacyCompassDB } from "./legacy-primary-key.migration"; describe("IndexedDBAdapter", () => { let adapter: IndexedDBAdapter; + let consoleLogSpy: jest.SpyInstance; + let consoleWarnSpy: jest.SpyInstance; beforeEach(async () => { + consoleLogSpy = jest.spyOn(console, "log").mockImplementation(); + consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); await clearCompassLocalDb(); adapter = new IndexedDBAdapter(); }); afterEach(async () => { + adapter.close(); await clearCompassLocalDb(); + consoleLogSpy.mockRestore(); + consoleWarnSpy.mockRestore(); }); describe("initialize", () => { diff --git a/packages/web/src/common/storage/adapter/indexeddb.adapter.ts b/packages/web/src/common/storage/adapter/indexeddb.adapter.ts index 21971b111..1daf5074b 100644 --- a/packages/web/src/common/storage/adapter/indexeddb.adapter.ts +++ b/packages/web/src/common/storage/adapter/indexeddb.adapter.ts @@ -113,6 +113,11 @@ export class IndexedDBAdapter implements StorageAdapter { return this.initialized && this.db.isOpen(); } + close(): void { + this.db.close(); + this.initialized = false; + } + // ─── Task Operations ─────────────────────────────────────────────────────── async getTasks(dateKey: string): Promise { diff --git a/packages/web/src/common/storage/migrations/external/localstorage-tasks.test.ts b/packages/web/src/common/storage/migrations/external/localstorage-tasks.test.ts index 34b3d3f7a..a0886990f 100644 --- a/packages/web/src/common/storage/migrations/external/localstorage-tasks.test.ts +++ b/packages/web/src/common/storage/migrations/external/localstorage-tasks.test.ts @@ -163,6 +163,8 @@ describe("localStorageTasksMigration", () => { }); it("skips invalid JSON entries and keeps them for retry", async () => { + jest.spyOn(console, "error").mockImplementation(() => {}); + const validTask = createMockTask({ _id: "task-1" }); const dateKey = "2025-01-15"; const validKey = `${TASK_KEY_PREFIX}${dateKey}`; diff --git a/packages/web/src/common/storage/migrations/external/localstorage-tasks.ts b/packages/web/src/common/storage/migrations/external/localstorage-tasks.ts index de531fadf..09db9694b 100644 --- a/packages/web/src/common/storage/migrations/external/localstorage-tasks.ts +++ b/packages/web/src/common/storage/migrations/external/localstorage-tasks.ts @@ -120,12 +120,6 @@ export const localStorageTasksMigration: ExternalMigration = { for (const key of keysToRemove) { localStorage.removeItem(key); } - - if (totalMigrated > 0) { - console.log( - `[Migration] Migrated ${totalMigrated} tasks from localStorage`, - ); - } }, isComplete(): boolean { diff --git a/packages/web/src/common/styles/default-theme.d.ts b/packages/web/src/common/styles/default-theme.d.ts index 67d28d78a..1bd5fd12b 100644 --- a/packages/web/src/common/styles/default-theme.d.ts +++ b/packages/web/src/common/styles/default-theme.d.ts @@ -1,5 +1,5 @@ import "styled-components"; -import { type textDark, type textLight } from "./colors"; +import { type textLight } from "./colors"; declare module "styled-components" { export interface DefaultTheme { diff --git a/packages/web/src/common/utils/cleanup/browser.cleanup.util.test.ts b/packages/web/src/common/utils/cleanup/browser.cleanup.util.test.ts index 4e4ff6a46..dbfc25271 100644 --- a/packages/web/src/common/utils/cleanup/browser.cleanup.util.test.ts +++ b/packages/web/src/common/utils/cleanup/browser.cleanup.util.test.ts @@ -73,10 +73,12 @@ describe("browser.cleanup.util", () => { }); it("should handle errors gracefully", async () => { + const spy = jest.spyOn(console, "error").mockImplementation(); const mockSignOut = session.signOut as jest.Mock; mockSignOut.mockRejectedValueOnce(new Error("Sign out failed")); await expect(clearAllBrowserStorage()).rejects.toThrow("Sign out failed"); + spy.mockRestore(); }); it("should not fail when no Compass storage exists", async () => { @@ -164,6 +166,7 @@ describe("browser.cleanup.util", () => { }); it("should handle IndexedDB deletion error", async () => { + const spy = jest.spyOn(console, "error").mockImplementation(); const mockDeleteRequest = { onsuccess: null as (() => void) | null, onerror: null as (() => void) | null, @@ -187,6 +190,7 @@ describe("browser.cleanup.util", () => { await expect(clearAllBrowserStorage()).rejects.toThrow( "Failed to delete IndexedDB", ); + spy.mockRestore(); }); it("should handle IndexedDB deletion blocked gracefully", async () => { diff --git a/packages/web/src/common/utils/sync/local-event-sync.util.test.ts b/packages/web/src/common/utils/sync/local-event-sync.util.test.ts index 89941ecb2..cc3adbc53 100644 --- a/packages/web/src/common/utils/sync/local-event-sync.util.test.ts +++ b/packages/web/src/common/utils/sync/local-event-sync.util.test.ts @@ -9,6 +9,7 @@ import { syncLocalEventsToCloud } from "./local-event-sync.util"; describe("syncLocalEventsToCloud", () => { const mockCreate = jest.spyOn(EventApi, "create"); + const mockConsoleLog = jest.spyOn(console, "log").mockImplementation(); const createMockEvent = (overrides?: Partial) => createMockStandaloneEvent(overrides) as Event_Core; @@ -19,6 +20,10 @@ describe("syncLocalEventsToCloud", () => { await getStorageAdapter().clearAllEvents(); }); + afterAll(() => { + mockConsoleLog.mockRestore(); + }); + afterEach(() => { mockCreate.mockClear(); }); diff --git a/packages/web/src/common/utils/sync/local-event-sync.util.ts b/packages/web/src/common/utils/sync/local-event-sync.util.ts index f0cc55ecb..35696568e 100644 --- a/packages/web/src/common/utils/sync/local-event-sync.util.ts +++ b/packages/web/src/common/utils/sync/local-event-sync.util.ts @@ -1,4 +1,3 @@ -import { type Event_Core } from "@core/types/event.types"; import { ensureStorageReady, getStorageAdapter, diff --git a/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.test.tsx b/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.test.tsx index 89cb981b6..06e3f5f5b 100644 --- a/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.test.tsx +++ b/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.test.tsx @@ -1,5 +1,6 @@ +import { act } from "react"; import "@testing-library/jest-dom"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ForgotPasswordForm } from "./ForgotPasswordForm"; @@ -26,8 +27,10 @@ describe("ForgotPasswordForm", () => { renderForgotPasswordForm(); const emailInput = screen.getByLabelText(/email/i); - await user.click(emailInput); - await user.type(emailInput, "invalid"); + await act(async () => { + await user.click(emailInput); + await user.type(emailInput, "invalid"); + }); expect( screen.queryByText(/please enter a valid email address/i), @@ -38,14 +41,14 @@ describe("ForgotPasswordForm", () => { const user = userEvent.setup(); renderForgotPasswordForm(); - await user.type(screen.getByLabelText(/email/i), "invalid-email"); - await user.tab(); - - await waitFor(() => { - expect( - screen.getByText(/please enter a valid email address/i), - ).toBeInTheDocument(); + await act(async () => { + await user.type(screen.getByLabelText(/email/i), "invalid-email"); + await user.tab(); }); + + expect( + screen.getByText(/please enter a valid email address/i), + ).toBeInTheDocument(); }); it("clears email error when user types after blur", async () => { @@ -53,23 +56,23 @@ describe("ForgotPasswordForm", () => { renderForgotPasswordForm(); const emailInput = screen.getByLabelText(/email/i); - await user.type(emailInput, "invalid"); - await user.tab(); - - await waitFor(() => { - expect( - screen.getByText(/please enter a valid email address/i), - ).toBeInTheDocument(); + await act(async () => { + await user.type(emailInput, "invalid"); + await user.tab(); }); - await user.click(emailInput); - await user.type(emailInput, "@example.com"); + expect( + screen.getByText(/please enter a valid email address/i), + ).toBeInTheDocument(); - await waitFor(() => { - expect( - screen.queryByText(/please enter a valid email address/i), - ).not.toBeInTheDocument(); + await act(async () => { + await user.click(emailInput); + await user.type(emailInput, "@example.com"); }); + + expect( + screen.queryByText(/please enter a valid email address/i), + ).not.toBeInTheDocument(); }); }); @@ -78,34 +81,36 @@ describe("ForgotPasswordForm", () => { renderForgotPasswordForm(); const form = screen.getByLabelText(/email/i).closest("form"); - if (form) fireEvent.submit(form); - - await waitFor(() => { - expect(screen.getByText(/email is required/i)).toBeInTheDocument(); + act(() => { + if (form) form.requestSubmit(); }); + + expect(screen.getByText(/email is required/i)).toBeInTheDocument(); }); it("shows email format error when submitting invalid email", async () => { const user = userEvent.setup(); renderForgotPasswordForm(); - await user.type(screen.getByLabelText(/email/i), "not-an-email"); - await user.click( - screen.getByRole("button", { name: /send reset link/i }), - ); - - await waitFor(() => { - expect( - screen.getByText(/please enter a valid email address/i), - ).toBeInTheDocument(); + await act(async () => { + await user.type(screen.getByLabelText(/email/i), "not-an-email"); + await user.click( + screen.getByRole("button", { name: /send reset link/i }), + ); }); + + expect( + screen.getByText(/please enter a valid email address/i), + ).toBeInTheDocument(); }); it("does not call onSubmit when form is invalid", () => { renderForgotPasswordForm(); const form = screen.getByLabelText(/email/i).closest("form"); - if (form) fireEvent.submit(form); + act(() => { + if (form) form.requestSubmit(); + }); expect(mockOnSubmit).not.toHaveBeenCalled(); }); @@ -114,25 +119,22 @@ describe("ForgotPasswordForm", () => { const user = userEvent.setup(); renderForgotPasswordForm(); - await user.type(screen.getByLabelText(/email/i), "test@example.com"); - await user.click( - screen.getByRole("button", { name: /send reset link/i }), - ); - - await waitFor(() => { - expect(mockOnSubmit).toHaveBeenCalledWith({ - email: "test@example.com", - }); + await act(async () => { + await user.type(screen.getByLabelText(/email/i), "test@example.com"); + await user.click( + screen.getByRole("button", { name: /send reset link/i }), + ); }); - await waitFor(() => { - expect(screen.getByText(/check your email/i)).toBeInTheDocument(); + expect(mockOnSubmit).toHaveBeenCalledWith({ + email: "test@example.com", }); + expect(screen.getByText(/check your email/i)).toBeInTheDocument(); }); }); describe("submit button state", () => { - it("disables submit when email is empty", async () => { + it("disables submit when email is empty", () => { renderForgotPasswordForm(); const submitButton = screen.getByRole("button", { @@ -145,7 +147,9 @@ describe("ForgotPasswordForm", () => { const user = userEvent.setup(); renderForgotPasswordForm(); - await user.type(screen.getByLabelText(/email/i), "test@example.com"); + await act(async () => { + await user.type(screen.getByLabelText(/email/i), "test@example.com"); + }); const submitButton = screen.getByRole("button", { name: /send reset link/i, @@ -159,16 +163,16 @@ describe("ForgotPasswordForm", () => { const user = userEvent.setup(); renderForgotPasswordForm(); - await user.type(screen.getByLabelText(/email/i), "user@example.com"); - await user.click( - screen.getByRole("button", { name: /send reset link/i }), - ); - - await waitFor(() => { - expect( - screen.getByText(/if an account exists for user@example.com/i), - ).toBeInTheDocument(); + await act(async () => { + await user.type(screen.getByLabelText(/email/i), "user@example.com"); + await user.click( + screen.getByRole("button", { name: /send reset link/i }), + ); }); + + expect( + screen.getByText(/if an account exists for user@example.com/i), + ).toBeInTheDocument(); }); }); @@ -177,9 +181,11 @@ describe("ForgotPasswordForm", () => { const user = userEvent.setup(); renderForgotPasswordForm(); - await user.click( - screen.getByRole("button", { name: /back to sign in/i }), - ); + await act(async () => { + await user.click( + screen.getByRole("button", { name: /back to sign in/i }), + ); + }); expect(mockOnBackToSignIn).toHaveBeenCalled(); }); diff --git a/packages/web/src/components/AuthModal/forms/SignUpForm.test.tsx b/packages/web/src/components/AuthModal/forms/SignUpForm.test.tsx index f07c5c823..a26758380 100644 --- a/packages/web/src/components/AuthModal/forms/SignUpForm.test.tsx +++ b/packages/web/src/components/AuthModal/forms/SignUpForm.test.tsx @@ -1,5 +1,6 @@ +import { act } from "react"; import "@testing-library/jest-dom"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { SignUpForm } from "./SignUpForm"; @@ -28,8 +29,10 @@ describe("SignUpForm", () => { renderSignUpForm(); const emailInput = screen.getByLabelText(/email/i); - await user.click(emailInput); - await user.type(emailInput, "invalid"); + await act(async () => { + await user.click(emailInput); + await user.type(emailInput, "invalid"); + }); expect( screen.queryByText(/please enter a valid email address/i), @@ -40,28 +43,28 @@ describe("SignUpForm", () => { const user = userEvent.setup(); renderSignUpForm(); - await user.type(screen.getByLabelText(/email/i), "invalid-email"); - await user.tab(); - - await waitFor(() => { - expect( - screen.getByText(/please enter a valid email address/i), - ).toBeInTheDocument(); + await act(async () => { + await user.type(screen.getByLabelText(/email/i), "invalid-email"); + await user.tab(); }); + + expect( + screen.getByText(/please enter a valid email address/i), + ).toBeInTheDocument(); }); it("shows password error only after blur", async () => { const user = userEvent.setup(); renderSignUpForm(); - await user.type(screen.getByLabelText(/password/i), "short"); - await user.tab(); - - await waitFor(() => { - expect( - screen.getByText(/password must be at least 8 characters/i), - ).toBeInTheDocument(); + await act(async () => { + await user.type(screen.getByLabelText(/password/i), "short"); + await user.tab(); }); + + expect( + screen.getByText(/password must be at least 8 characters/i), + ).toBeInTheDocument(); }); it("clears error when user types after blur", async () => { @@ -69,50 +72,54 @@ describe("SignUpForm", () => { renderSignUpForm(); const passwordInput = screen.getByLabelText(/password/i); - await user.type(passwordInput, "short"); - await user.tab(); - - await waitFor(() => { - expect( - screen.getByText(/password must be at least 8 characters/i), - ).toBeInTheDocument(); + await act(async () => { + await user.type(passwordInput, "short"); + await user.tab(); }); - await user.click(passwordInput); - await user.type(passwordInput, "er123456"); + expect( + screen.getByText(/password must be at least 8 characters/i), + ).toBeInTheDocument(); - await waitFor(() => { - expect( - screen.queryByText(/password must be at least 8 characters/i), - ).not.toBeInTheDocument(); + await act(async () => { + await user.click(passwordInput); + await user.type(passwordInput, "er123456"); }); + + expect( + screen.queryByText(/password must be at least 8 characters/i), + ).not.toBeInTheDocument(); }); }); describe("submit validation", () => { - it("shows all field errors when submitting empty form", async () => { + it("shows all field errors when submitting empty form", () => { renderSignUpForm(); const form = screen.getByLabelText(/name/i).closest("form"); - if (form) fireEvent.submit(form); - - await waitFor(() => { - expect(screen.getByText(/name is required/i)).toBeInTheDocument(); - expect(screen.getByText(/email is required/i)).toBeInTheDocument(); - expect(screen.getByText(/password is required/i)).toBeInTheDocument(); + act(() => { + if (form) form.requestSubmit(); }); + + expect(screen.getByText(/name is required/i)).toBeInTheDocument(); + expect(screen.getByText(/email is required/i)).toBeInTheDocument(); + expect(screen.getByText(/password is required/i)).toBeInTheDocument(); }); it("does not call onSubmit when form is invalid", async () => { const user = userEvent.setup(); renderSignUpForm(); - await user.type(screen.getByLabelText(/name/i), "Alex"); - await user.type(screen.getByLabelText(/email/i), "invalid"); - await user.type(screen.getByLabelText(/password/i), "short"); + await act(async () => { + await user.type(screen.getByLabelText(/name/i), "Alex"); + await user.type(screen.getByLabelText(/email/i), "invalid"); + await user.type(screen.getByLabelText(/password/i), "short"); + }); const form = screen.getByLabelText(/name/i).closest("form"); - if (form) fireEvent.submit(form); + act(() => { + if (form) form.requestSubmit(); + }); expect(mockOnSubmit).not.toHaveBeenCalled(); }); @@ -121,17 +128,17 @@ describe("SignUpForm", () => { const user = userEvent.setup(); renderSignUpForm(); - await user.type(screen.getByLabelText(/name/i), "Alex Smith"); - await user.type(screen.getByLabelText(/email/i), "alex@example.com"); - await user.type(screen.getByLabelText(/password/i), "securepass123"); - await user.click(screen.getByRole("button", { name: /sign up/i })); + await act(async () => { + await user.type(screen.getByLabelText(/name/i), "Alex Smith"); + await user.type(screen.getByLabelText(/email/i), "alex@example.com"); + await user.type(screen.getByLabelText(/password/i), "securepass123"); + await user.click(screen.getByRole("button", { name: /sign up/i })); + }); - await waitFor(() => { - expect(mockOnSubmit).toHaveBeenCalledWith({ - name: "Alex Smith", - email: "alex@example.com", - password: "securepass123", - }); + expect(mockOnSubmit).toHaveBeenCalledWith({ + name: "Alex Smith", + email: "alex@example.com", + password: "securepass123", }); }); @@ -139,23 +146,26 @@ describe("SignUpForm", () => { const user = userEvent.setup(); renderSignUpForm(); - await user.type(screen.getByLabelText(/name/i), " Alex "); - await user.type(screen.getByLabelText(/email/i), " Alex@Example.COM "); - await user.type(screen.getByLabelText(/password/i), "password123"); - await user.click(screen.getByRole("button", { name: /sign up/i })); + await act(async () => { + await user.type(screen.getByLabelText(/name/i), " Alex "); + await user.type( + screen.getByLabelText(/email/i), + " Alex@Example.COM ", + ); + await user.type(screen.getByLabelText(/password/i), "password123"); + await user.click(screen.getByRole("button", { name: /sign up/i })); + }); - await waitFor(() => { - expect(mockOnSubmit).toHaveBeenCalledWith({ - name: "Alex", - email: "alex@example.com", - password: "password123", - }); + expect(mockOnSubmit).toHaveBeenCalledWith({ + name: "Alex", + email: "alex@example.com", + password: "password123", }); }); }); describe("submit button state", () => { - it("disables submit when form is invalid", async () => { + it("disables submit when form is invalid", () => { renderSignUpForm(); const submitButton = screen.getByRole("button", { @@ -168,9 +178,11 @@ describe("SignUpForm", () => { const user = userEvent.setup(); renderSignUpForm(); - await user.type(screen.getByLabelText(/name/i), "Alex"); - await user.type(screen.getByLabelText(/email/i), "alex@example.com"); - await user.type(screen.getByLabelText(/password/i), "password123"); + await act(async () => { + await user.type(screen.getByLabelText(/name/i), "Alex"); + await user.type(screen.getByLabelText(/email/i), "alex@example.com"); + await user.type(screen.getByLabelText(/password/i), "password123"); + }); const submitButton = screen.getByRole("button", { name: /sign up/i, @@ -184,7 +196,9 @@ describe("SignUpForm", () => { const user = userEvent.setup(); renderSignUpForm(); - await user.type(screen.getByLabelText(/name/i), "Alex"); + await act(async () => { + await user.type(screen.getByLabelText(/name/i), "Alex"); + }); expect(mockOnNameChange).toHaveBeenCalledWith("A"); expect(mockOnNameChange).toHaveBeenCalledWith("Al"); diff --git a/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.test.tsx b/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.test.tsx index 7611fc0a9..c23a86b09 100644 --- a/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.test.tsx +++ b/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.test.tsx @@ -1,7 +1,7 @@ -import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { Route, Routes } from "react-router-dom"; import "@testing-library/jest-dom"; import { screen } from "@testing-library/react"; -import { render } from "@web/__tests__/__mocks__/mock.render"; +import { renderWithMemoryRouter } from "@web/__tests__/utils/providers/MemoryRouter"; import { AuthenticatedLayout } from "./AuthenticatedLayout"; describe("AuthenticatedLayout", () => { @@ -9,39 +9,35 @@ describe("AuthenticatedLayout", () => { jest.clearAllMocks(); }); - it("should render child routes via Outlet", () => { - render( - - - }> - Child Content} - /> - - - , + it("should render child routes via Outlet", async () => { + await renderWithMemoryRouter( + + }> + Child Content} + /> + + , ); expect(screen.getByTestId("child-route")).toBeInTheDocument(); expect(screen.getByText("Child Content")).toBeInTheDocument(); }); - it("should render nested routes correctly", () => { - render( - - - }> - Nested Content} - /> - - - , + it("should render nested routes correctly", async () => { + await renderWithMemoryRouter( + + }> + Nested Content} + /> + + , + ["/nested"], ); - // Wait for the route to render expect(screen.getByTestId("nested-route")).toBeInTheDocument(); expect(screen.getByText("Nested Content")).toBeInTheDocument(); });