diff --git a/docs/development/testing-playbook.md b/docs/development/testing-playbook.md index bc10f58eb..b2caec962 100644 --- a/docs/development/testing-playbook.md +++ b/docs/development/testing-playbook.md @@ -45,8 +45,8 @@ E2E workflow (`test-e2e.yml`) is separate and runs on pull requests to `main` vi ## Current Test Strategy - `bun run test:core` uses `bun test` with a small compatibility preload for the core BSON mock setup. -- `bun run test:web`, `bun run test:backend`, and `bun run test:scripts` intentionally retain the existing Jest harness while their hoist-heavy module-mocking patterns are migrated. -- `bun run test:` is the stable CI-facing entrypoint for every package; the root dispatcher chooses the correct runner per project. +- `bun run test:web` runs `bun test --cwd packages/web` directly. Web tests should be isolated enough to run in one Bun process without batching. +- `bun run test:backend` and `bun run test:scripts` intentionally retain the existing Jest harness while their hoist-heavy module-mocking patterns are migrated. ## Retained Jest Layout @@ -116,6 +116,14 @@ Avoid: - implementation-detail assertions - unnecessary module-wide mocks +Isolation rules: + +- Do not use top-level `mock.module` for shared production modules unless the test imports the subject through a local factory and the mock cannot affect later files. Prefer provider wrappers, real stores, explicit dependency factories, or `spyOn` with teardown. +- Avoid mocking shared UI primitives such as `TooltipWrapper`, `@floating-ui/react`, or session hooks in broad component tests. A mock that only helps one file can change unrelated tests later in the same Bun process. +- If a test replaces globals (`fetch`, `document.getElementById`, storage, timers, console methods), restore the original value in teardown. +- Prefer `renderWithStore`, `createStoreWrapper`, or a focused provider harness over mocking `@web/store` or `store.hooks`. +- `bun test --cwd packages/web` is the acceptance check for web test isolation. A focused test can pass while still leaking into the direct suite. + ### Web Jest Harness Defaults (MSW + Globals) Primary setup files: diff --git a/package.json b/package.json index f2cef3e0d..693502f2e 100644 --- a/package.json +++ b/package.json @@ -26,12 +26,12 @@ "lint:fix": "biome check --write .", "format": "biome format --write .", "format:check": "biome format .", - "test": "bun packages/scripts/src/testing/run.ts", + "test": "bun test:core && bun test:web && bun test:backend && bun test:scripts", "test:e2e": "bunx playwright test", - "test:backend": "bun packages/scripts/src/testing/run.ts backend", - "test:core": "bun packages/scripts/src/testing/run.ts core", - "test:web": "bun packages/scripts/src/testing/run.ts web", - "test:scripts": "bun packages/scripts/src/testing/run.ts scripts", + "test:backend": "./node_modules/.bin/jest --selectProjects backend", + "test:core": "bun test packages/core/src --preload packages/scripts/src/testing/core.preload.ts", + "test:web": "bun test --cwd packages/web", + "test:scripts": "./node_modules/.bin/jest scripts", "type-check": "bunx typescript@6.0.3 --noEmit && bunx typescript@6.0.3 -p packages/web/tsconfig.app.json --noEmit && bun run type-check:web-tests", "type-check:web-tests": "bunx typescript@6.0.3 -p packages/web/tsconfig.test.json --noEmit", "verify": "bun packages/scripts/src/testing/verify.ts", diff --git a/packages/scripts/src/testing/run.ts b/packages/scripts/src/testing/run.ts deleted file mode 100644 index ecb451c2b..000000000 --- a/packages/scripts/src/testing/run.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { existsSync, readdirSync, statSync } from "node:fs"; -import { relative, resolve } from "node:path"; - -type BunRuntime = { - spawnSync(input: { - cmd: string[]; - cwd?: string; - env?: Record; - stderr?: "inherit"; - stdin?: "inherit"; - stdout?: "inherit"; - }): { exitCode: number }; -}; - -type ProjectConfig = { - cmd: string[]; - cwd?: string; -}; - -const bunRuntime = (globalThis as unknown as { Bun: BunRuntime }).Bun; -const WEB_ROOT = resolve(process.cwd(), "packages/web"); -const WEB_SRC = resolve(WEB_ROOT, "src"); -const WEB_TEST_FILE_PATTERN = /\.(spec|test)\.[tj]sx?$/; - -const TEST_PROJECTS = { - backend: { - cmd: ["./node_modules/.bin/jest", "--selectProjects", "backend"], - }, - core: { - cmd: [ - "bun", - "test", - "packages/core/src", - "--preload", - "packages/scripts/src/testing/core.preload.ts", - ], - }, - scripts: { - cmd: ["./node_modules/.bin/jest", "scripts"], - }, - web: { - cmd: [], - }, -} satisfies Record; - -function findWebTestFiles(dir: string): string[] { - return readdirSync(dir) - .flatMap((entry) => { - const path = resolve(dir, entry); - const stats = statSync(path); - - if (stats.isDirectory()) { - return findWebTestFiles(path); - } - - if (!WEB_TEST_FILE_PATTERN.test(entry)) { - return []; - } - - return relative(WEB_ROOT, path); - }) - .sort(); -} - -function assertBackendConfigFile() { - const configFilePath = resolve(process.cwd(), "compass.yaml"); - - if (!existsSync(configFilePath)) { - return; - } - - process.env["BUN_CONFIG_NO_CLEAR_TERMINAL_ON_RELOAD"] = "true"; -} - -function runCommand(cmd: string[], cwd = process.cwd()) { - const result = bunRuntime.spawnSync({ - cmd, - cwd, - env: { - ...process.env, - NODE_ENV: "test", - TZ: process.env["TZ"] ?? "Etc/UTC", - }, - stderr: "inherit", - stdin: "inherit", - stdout: "inherit", - }); - - if (result.exitCode !== 0) { - process.exit(result.exitCode); - } -} - -function runWebProject() { - for (const testFile of findWebTestFiles(WEB_SRC)) { - runCommand([process.execPath, "test", "--cwd", WEB_ROOT, testFile]); - } -} - -function runProject(projectName: keyof typeof TEST_PROJECTS) { - if (projectName === "web") { - runWebProject(); - return; - } - - runCommand(TEST_PROJECTS[projectName].cmd); -} - -function main() { - assertBackendConfigFile(); - - const requestedProject = process.argv[2] as - | keyof typeof TEST_PROJECTS - | undefined; - - if (requestedProject) { - runProject(requestedProject); - return; - } - - for (const projectName of Object.keys(TEST_PROJECTS) as Array< - keyof typeof TEST_PROJECTS - >) { - runProject(projectName); - } -} - -main(); diff --git a/packages/scripts/src/testing/verify.ts b/packages/scripts/src/testing/verify.ts index 64e220685..1971fba8c 100644 --- a/packages/scripts/src/testing/verify.ts +++ b/packages/scripts/src/testing/verify.ts @@ -5,7 +5,7 @@ import { execSync } from "node:child_process"; * Detects which packages changed via git diff and runs the minimum * necessary test suites plus type-check. * - * All test execution is delegated to run.ts — no commands are duplicated here. + * Test execution is delegated to the root `test:` package.json scripts. * * Usage: * bun run verify — auto-detect from git diff @@ -79,7 +79,7 @@ function mapFilesToPackages(files: string[]): Package[] { function runPackage(pkg: Package): boolean { console.log(`\n→ test:${pkg}`); const result = bunRuntime.spawnSync({ - cmd: ["bun", "packages/scripts/src/testing/run.ts", pkg], + cmd: ["bun", "run", `test:${pkg}`], cwd: process.cwd(), env: { ...process.env, diff --git a/packages/web/src/__tests__/render-with-store.tsx b/packages/web/src/__tests__/render-with-store.tsx new file mode 100644 index 000000000..bbcc49b0b --- /dev/null +++ b/packages/web/src/__tests__/render-with-store.tsx @@ -0,0 +1,59 @@ +import { configureStore, type PreloadedState } from "@reduxjs/toolkit"; +import { + type RenderHookOptions, + render, + renderHook, +} from "@testing-library/react"; +import { type PropsWithChildren, type ReactElement } from "react"; +import { Provider } from "react-redux"; +import { sagaMiddleware } from "@web/common/store/middlewares"; +import { type RootState } from "@web/store"; +import { reducers } from "@web/store/reducers"; + +export function createTestStore(preloadedState?: PreloadedState) { + return configureStore({ + reducer: reducers, + preloadedState, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + thunk: false, + serializableCheck: false, + immutableCheck: false, + }).concat(sagaMiddleware), + }); +} + +export function createStoreWrapper(preloadedState?: PreloadedState) { + const store = createTestStore(preloadedState); + + function StoreWrapper({ children }: PropsWithChildren) { + return {children}; + } + + return { store, wrapper: StoreWrapper }; +} + +export function renderWithStore( + ui: ReactElement, + preloadedState?: PreloadedState, +) { + const { store, wrapper } = createStoreWrapper(preloadedState); + + return { + store, + ...render(ui, { wrapper }), + }; +} + +export function renderHookWithStore( + hook: (initialProps: Props) => Result, + preloadedState?: PreloadedState, + options?: Omit, "wrapper">, +) { + const { store, wrapper } = createStoreWrapper(preloadedState); + + return { + store, + ...renderHook(hook, { ...options, wrapper }), + }; +} diff --git a/packages/web/src/__tests__/web.preload.ts b/packages/web/src/__tests__/web.preload.ts index a476e3682..b114edfa4 100644 --- a/packages/web/src/__tests__/web.preload.ts +++ b/packages/web/src/__tests__/web.preload.ts @@ -274,6 +274,32 @@ mockNodeModules(); const sessionModule = await import("supertokens-web-js/recipe/session"); const { cleanup } = await import("@testing-library/react"); +function resetDocument() { + document.body.innerHTML = ""; + document.body.removeAttribute("style"); + document.body.removeAttribute("class"); + document.body.removeAttribute("data-app-locked"); + document.documentElement.removeAttribute("style"); + for (const style of document.head.querySelectorAll("style")) { + if ( + !style.textContent?.includes(":has(.react-datepicker__day--selected)") + ) { + continue; + } + + style.textContent = style.textContent.replaceAll( + /[^{}]+:has\(\.react-datepicker__day--selected\)[^{]*\{[^{}]*\}/g, + "", + ); + } +} + +function resetBrowserState() { + dom.reconfigure({ url: "http://localhost/" }); + localStorage.clear(); + sessionStorage.clear(); +} + beforeEach(() => { sessionModule.doesSessionExist?.mockResolvedValue(true); }); @@ -282,6 +308,8 @@ beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(async () => { await Promise.resolve(); cleanup(); + resetDocument(); + resetBrowserState(); server.resetHandlers(); }); afterAll(() => server.close()); diff --git a/packages/web/src/auth/compass/hooks/useCompleteAuthentication.factory.ts b/packages/web/src/auth/compass/hooks/useCompleteAuthentication.factory.ts new file mode 100644 index 000000000..7492b5db1 --- /dev/null +++ b/packages/web/src/auth/compass/hooks/useCompleteAuthentication.factory.ts @@ -0,0 +1,43 @@ +type Dispatch = (action: unknown) => unknown; + +export type CompleteAuthenticationDependencies = { + authSuccess: () => unknown; + clearAnonymousCalendarChangeSignUpPrompt: () => void; + markUserAsAuthenticated: (email?: string) => void; + refreshUserMetadata: () => Promise | unknown; + syncPendingLocalEvents: () => Promise; + triggerFetch: () => unknown; + useAppDispatch: () => Dispatch; + useSession: () => { + setAuthenticated: (isAuthenticated: boolean) => void; + }; +}; + +export function createUseCompleteAuthentication( + dependencies: CompleteAuthenticationDependencies, +) { + return function useCompleteAuthenticationWithDependencies() { + const dispatch = dependencies.useAppDispatch(); + const { setAuthenticated } = dependencies.useSession(); + + return async ({ + email, + onComplete, + }: { + email?: string; + onComplete?: () => void; + }) => { + dependencies.clearAnonymousCalendarChangeSignUpPrompt(); + dependencies.markUserAsAuthenticated(email); + setAuthenticated(true); + dispatch(dependencies.authSuccess()); + + void dependencies.refreshUserMetadata(); + + await dependencies.syncPendingLocalEvents(); + + dispatch(dependencies.triggerFetch()); + onComplete?.(); + }; + }; +} diff --git a/packages/web/src/auth/compass/hooks/useCompleteAuthentication.test.ts b/packages/web/src/auth/compass/hooks/useCompleteAuthentication.test.ts index 4229e3d15..3f623d988 100644 --- a/packages/web/src/auth/compass/hooks/useCompleteAuthentication.test.ts +++ b/packages/web/src/auth/compass/hooks/useCompleteAuthentication.test.ts @@ -1,78 +1,91 @@ -import { afterAll, beforeEach, describe, it, mock } from "bun:test"; - -// Pre-define mock functions -const mockSyncPendingLocalEvents = mock(); -const mockUseSession = mock(); -const mockRefreshUserMetadata = mock(); -const mockUseAppDispatch = mock(); - -mock.module("@web/auth/google/util/google.auth.util", () => ({ - authenticate: mock(), - handleGoogleRevoked: mock(), - showLocalEventsSyncFailure: mock(), - syncLocalEvents: mock(), - syncPendingLocalEvents: mockSyncPendingLocalEvents, -})); - -mock.module("@web/auth/compass/session/useSession", () => ({ - useSession: mockUseSession, -})); - -mock.module("@web/auth/compass/user/util/user-metadata.util", () => ({ - refreshUserMetadata: mockRefreshUserMetadata, -})); - -mock.module("@web/store/store.hooks", () => ({ - useAppDispatch: mockUseAppDispatch, -})); - -// Mock the ducks/slices to avoid loading their dependencies if they trigger side effects -mock.module("@web/ducks/auth/slices/auth.slice", () => ({ - authSuccess: mock().mockReturnValue({ type: "auth/authSuccess" }), -})); - -mock.module("@web/ducks/events/slices/sync.slice", () => ({ - triggerFetch: mock().mockReturnValue({ type: "importLatest/triggerFetch" }), -})); - import { renderHook } from "@testing-library/react"; - -const { useCompleteAuthentication } = require("./useCompleteAuthentication"); +import { createUseCompleteAuthentication } from "./useCompleteAuthentication.factory"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +const authSuccessAction = { type: "auth/authSuccess" }; +const triggerFetchAction = { type: "importLatest/triggerFetch" }; + +const dependencies = { + authSuccess: mock(() => authSuccessAction), + clearAnonymousCalendarChangeSignUpPrompt: mock(), + markUserAsAuthenticated: mock(), + refreshUserMetadata: mock(), + syncPendingLocalEvents: mock(), + triggerFetch: mock(() => triggerFetchAction), + useAppDispatch: mock(), + useSession: mock(), +}; describe("useCompleteAuthentication", () => { - const mockDispatch = mock(); - const mockSetAuthenticated = mock(); + const dispatch = mock(); + const setAuthenticated = mock(); beforeEach(() => { - mockSyncPendingLocalEvents.mockClear(); - mockUseSession.mockClear(); - mockRefreshUserMetadata.mockClear(); - mockUseAppDispatch.mockClear(); - mockDispatch.mockClear(); - mockSetAuthenticated.mockClear(); - - mockUseAppDispatch.mockReturnValue(mockDispatch); - mockUseSession.mockReturnValue({ + dispatch.mockClear(); + setAuthenticated.mockClear(); + + dependencies.authSuccess.mockClear(); + dependencies.clearAnonymousCalendarChangeSignUpPrompt.mockClear(); + dependencies.markUserAsAuthenticated.mockClear(); + dependencies.refreshUserMetadata.mockClear(); + dependencies.syncPendingLocalEvents.mockClear(); + dependencies.triggerFetch.mockClear(); + dependencies.useAppDispatch.mockClear(); + dependencies.useSession.mockClear(); + + dependencies.useAppDispatch.mockReturnValue(dispatch); + dependencies.useSession.mockReturnValue({ authenticated: false, - setAuthenticated: mockSetAuthenticated, + setAuthenticated, }); - mockSyncPendingLocalEvents.mockResolvedValue(true); - mockRefreshUserMetadata.mockResolvedValue(true); + dependencies.refreshUserMetadata.mockResolvedValue(true); + dependencies.syncPendingLocalEvents.mockResolvedValue(true); }); - it("completes authentication and triggers fetch", async () => { + it("marks the user authenticated and triggers an event fetch", async () => { + const useCompleteAuthentication = + createUseCompleteAuthentication(dependencies); + const onComplete = mock(); const { result } = renderHook(() => useCompleteAuthentication()); - await Promise.resolve(result.current({ email: "test@example.com" })); + await result.current({ email: "test@example.com", onComplete }); + + expect( + dependencies.clearAnonymousCalendarChangeSignUpPrompt, + ).toHaveBeenCalled(); + expect(dependencies.markUserAsAuthenticated).toHaveBeenCalledWith( + "test@example.com", + ); + expect(setAuthenticated).toHaveBeenCalledWith(true); + expect(dispatch).toHaveBeenCalledWith(authSuccessAction); + expect(dependencies.refreshUserMetadata).toHaveBeenCalled(); + expect(dependencies.syncPendingLocalEvents).toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledWith(triggerFetchAction); + expect(onComplete).toHaveBeenCalled(); }); - it("records synced local events count", async () => { + it("waits for pending local event sync before completing", async () => { + const useCompleteAuthentication = + createUseCompleteAuthentication(dependencies); + const onComplete = mock(); const { result } = renderHook(() => useCompleteAuthentication()); - await Promise.resolve(result.current({ email: "test@example.com" })); - }); -}); + let resolveSync: (value: boolean) => void = () => {}; + dependencies.syncPendingLocalEvents.mockReturnValue( + new Promise((resolve) => { + resolveSync = resolve; + }), + ); + + const completion = result.current({ onComplete }); -afterAll(() => { - mock.restore(); + await Promise.resolve(); + + expect(onComplete).not.toHaveBeenCalled(); + + resolveSync(true); + await completion; + + expect(onComplete).toHaveBeenCalled(); + }); }); diff --git a/packages/web/src/auth/compass/hooks/useCompleteAuthentication.ts b/packages/web/src/auth/compass/hooks/useCompleteAuthentication.ts index bbdaa37df..2c7f7fe61 100644 --- a/packages/web/src/auth/compass/hooks/useCompleteAuthentication.ts +++ b/packages/web/src/auth/compass/hooks/useCompleteAuthentication.ts @@ -8,28 +8,15 @@ import { syncPendingLocalEvents } from "@web/auth/google/util/google.auth.util"; import { authSuccess } from "@web/ducks/auth/slices/auth.slice"; import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; import { useAppDispatch } from "@web/store/store.hooks"; +import { createUseCompleteAuthentication } from "./useCompleteAuthentication.factory"; -export function useCompleteAuthentication() { - const dispatch = useAppDispatch(); - const { setAuthenticated } = useSession(); - - return async ({ - email, - onComplete, - }: { - email?: string; - onComplete?: () => void; - }) => { - clearAnonymousCalendarChangeSignUpPrompt(); - markUserAsAuthenticated(email); - setAuthenticated(true); - dispatch(authSuccess()); - - void refreshUserMetadata(); - - await syncPendingLocalEvents(); - - dispatch(triggerFetch()); - onComplete?.(); - }; -} +export const useCompleteAuthentication = createUseCompleteAuthentication({ + authSuccess, + clearAnonymousCalendarChangeSignUpPrompt, + markUserAsAuthenticated, + refreshUserMetadata, + syncPendingLocalEvents, + triggerFetch, + useAppDispatch, + useSession, +}); diff --git a/packages/web/src/auth/compass/hooks/useLogout.test.ts b/packages/web/src/auth/compass/hooks/useLogout.test.ts index 30a06f480..243374c81 100644 --- a/packages/web/src/auth/compass/hooks/useLogout.test.ts +++ b/packages/web/src/auth/compass/hooks/useLogout.test.ts @@ -1,13 +1,5 @@ import { act, renderHook } from "@testing-library/react"; -import { - afterAll, - beforeEach, - describe, - expect, - it, - mock, - spyOn, -} from "bun:test"; +import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; const clearAuthenticationState = mock(); const setAuthenticated = mock(); @@ -89,7 +81,3 @@ describe("useLogout", () => { consoleWarn.mockRestore(); }); }); - -afterAll(() => { - mock.restore(); -}); diff --git a/packages/web/src/auth/compass/session/SessionProvider.test.tsx b/packages/web/src/auth/compass/session/SessionProvider.test.tsx index 03d178f92..aa146c1f2 100644 --- a/packages/web/src/auth/compass/session/SessionProvider.test.tsx +++ b/packages/web/src/auth/compass/session/SessionProvider.test.tsx @@ -1,8 +1,9 @@ import { act, renderHook, waitFor } from "@testing-library/react"; +import { useContext } from "react"; import { Subject } from "rxjs"; import { authSlice } from "@web/ducks/auth/slices/auth.slice"; import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; -import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; // Create mocks at module level const refreshUserMetadata = mock().mockResolvedValue(undefined); @@ -87,6 +88,7 @@ mock.module("@web/common/classes/Session", () => ({ events$.next(payload as { action: string }), on: mock(), off: mock(), + signOut: mock().mockResolvedValue(undefined), }, })); @@ -101,9 +103,8 @@ const { session } = require("@web/common/classes/Session") as { }; }; -const { SessionProvider, sessionInit } = +const { SessionContext, SessionProvider, sessionInit } = require("./SessionProvider") as typeof import("./SessionProvider"); -const { useSession } = require("./useSession") as typeof import("./useSession"); describe("SessionProvider sessionInit", () => { beforeEach(() => { @@ -170,7 +171,7 @@ describe("SessionProvider sessionInit", () => { getStream.mockReturnValue({} as EventSource); doesSessionExist.mockResolvedValue(false); - const { result } = renderHook(() => useSession(), { + const { result } = renderHook(() => useContext(SessionContext), { wrapper: SessionProvider, }); @@ -188,7 +189,3 @@ describe("SessionProvider sessionInit", () => { expect(result.current.authenticated).toBe(true); }); }); - -afterAll(() => { - mock.restore(); -}); diff --git a/packages/web/src/auth/compass/session/session.util.test.ts b/packages/web/src/auth/compass/session/session.util.test.ts index a16522c50..ab13c6855 100644 --- a/packages/web/src/auth/compass/session/session.util.test.ts +++ b/packages/web/src/auth/compass/session/session.util.test.ts @@ -1,6 +1,6 @@ import { UNAUTHENTICATED_USER } from "@web/common/constants/auth.constants"; import { getUserId } from "./session.util"; -import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; const mockDoesSessionExist = mock(); const mockGetAccessTokenPayloadSecurely = mock(); @@ -9,6 +9,7 @@ mock.module("@web/common/classes/Session", () => ({ session: { doesSessionExist: mockDoesSessionExist, getAccessTokenPayloadSecurely: mockGetAccessTokenPayloadSecurely, + signOut: mock().mockResolvedValue(undefined), }, })); @@ -44,7 +45,3 @@ describe("session.util", () => { }); }); }); - -afterAll(() => { - mock.restore(); -}); diff --git a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.factory.ts b/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.factory.ts new file mode 100644 index 000000000..111f58e90 --- /dev/null +++ b/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.factory.ts @@ -0,0 +1,88 @@ +import { useEffect, useSyncExternalStore } from "react"; + +type BackendGoogleAvailability = "available" | "unavailable" | "unknown"; + +type GoogleAvailabilityDependencies = { + getConfig: () => Promise<{ google?: { isConfigured?: boolean } }>; + isGoogleAuthConfigured: boolean; +}; + +export function createGoogleAvailability({ + getConfig, + isGoogleAuthConfigured, +}: GoogleAvailabilityDependencies) { + const listeners = new Set<() => void>(); + let backendGoogleAvailability: BackendGoogleAvailability = "unknown"; + let loadPromise: Promise | undefined; + + const emit = () => { + for (const listener of listeners) { + listener(); + } + }; + + const setBackendGoogleAvailability = ( + availability: BackendGoogleAvailability, + ) => { + backendGoogleAvailability = availability; + emit(); + }; + + const subscribeToBackendGoogleAvailability = (listener: () => void) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }; + + const getBackendGoogleAvailabilitySnapshot = (): boolean => + backendGoogleAvailability === "available"; + + const loadBackendGoogleAvailability = async (): Promise => { + if (!isGoogleAuthConfigured) { + setBackendGoogleAvailability("unavailable"); + return; + } + + if (!loadPromise) { + loadPromise = getConfig() + .then((config) => { + setBackendGoogleAvailability( + config.google?.isConfigured ? "available" : "unavailable", + ); + }) + .catch(() => { + loadPromise = undefined; + setBackendGoogleAvailability("unavailable"); + }); + } + + return loadPromise; + }; + + const useIsGoogleAvailable = (): boolean => { + const isBackendGoogleConfigured = useSyncExternalStore( + subscribeToBackendGoogleAvailability, + getBackendGoogleAvailabilitySnapshot, + getBackendGoogleAvailabilitySnapshot, + ); + + useEffect(() => { + void loadBackendGoogleAvailability(); + }, []); + + return isGoogleAuthConfigured && isBackendGoogleConfigured; + }; + + const resetGoogleAvailabilityForTests = () => { + backendGoogleAvailability = "unknown"; + loadPromise = undefined; + emit(); + }; + + return { + resetGoogleAvailabilityForTests, + useIsGoogleAvailable, + }; +} diff --git a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.test.tsx b/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.test.tsx index 0d07928c3..13f9f26bf 100644 --- a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.test.tsx +++ b/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.test.tsx @@ -1,26 +1,20 @@ import { renderHook, waitFor } from "@testing-library/react"; +import { createGoogleAvailability } from "./useIsGoogleAvailable.factory"; import { describe, expect, it, mock } from "bun:test"; const getConfig = mock(); -mock.module("@web/common/apis/app-config.api", () => ({ - AppConfigApi: { - get: getConfig, - }, -})); - -mock.module("@web/common/constants/env.constants", () => ({ - IS_GOOGLE_AUTH_CONFIGURED: true, -})); +const createHook = () => { + const { resetGoogleAvailabilityForTests, useIsGoogleAvailable } = + createGoogleAvailability({ + getConfig, + isGoogleAuthConfigured: true, + }); -async function importHook() { - const moduleUrl = new URL( - `./useIsGoogleAvailable.ts?test=${Math.random().toString(36).slice(2)}`, - import.meta.url, - ); + resetGoogleAvailabilityForTests(); - return import(moduleUrl.href); -} + return useIsGoogleAvailable; +}; describe("useIsGoogleAvailable", () => { it("uses the backend config response before exposing Google UI", async () => { @@ -30,9 +24,7 @@ describe("useIsGoogleAvailable", () => { isConfigured: true, }, }); - const { resetGoogleAvailabilityForTests, useIsGoogleAvailable } = - await importHook(); - resetGoogleAvailabilityForTests(); + const useIsGoogleAvailable = createHook(); const { result } = renderHook(() => useIsGoogleAvailable()); @@ -53,9 +45,7 @@ describe("useIsGoogleAvailable", () => { isConfigured: true, }, }); - const { resetGoogleAvailabilityForTests, useIsGoogleAvailable } = - await importHook(); - resetGoogleAvailabilityForTests(); + const useIsGoogleAvailable = createHook(); const firstRender = renderHook(() => useIsGoogleAvailable()); diff --git a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.ts b/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.ts index eee6849e2..41f21681b 100644 --- a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.ts +++ b/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.ts @@ -1,75 +1,11 @@ -import { useEffect, useSyncExternalStore } from "react"; import { AppConfigApi } from "@web/common/apis/app-config.api"; import { IS_GOOGLE_AUTH_CONFIGURED } from "@web/common/constants/env.constants"; +import { createGoogleAvailability } from "./useIsGoogleAvailable.factory"; -type BackendGoogleAvailability = "available" | "unavailable" | "unknown"; +const googleAvailability = createGoogleAvailability({ + getConfig: AppConfigApi.get, + isGoogleAuthConfigured: IS_GOOGLE_AUTH_CONFIGURED, +}); -const listeners = new Set<() => void>(); -let backendGoogleAvailability: BackendGoogleAvailability = "unknown"; -let loadPromise: Promise | undefined; - -const emit = () => { - for (const listener of listeners) { - listener(); - } -}; - -const setBackendGoogleAvailability = ( - availability: BackendGoogleAvailability, -) => { - backendGoogleAvailability = availability; - emit(); -}; - -const subscribeToBackendGoogleAvailability = (listener: () => void) => { - listeners.add(listener); - - return () => { - listeners.delete(listener); - }; -}; - -const getBackendGoogleAvailabilitySnapshot = (): boolean => - backendGoogleAvailability === "available"; - -const loadBackendGoogleAvailability = async (): Promise => { - if (!IS_GOOGLE_AUTH_CONFIGURED) { - setBackendGoogleAvailability("unavailable"); - return; - } - - if (!loadPromise) { - loadPromise = AppConfigApi.get() - .then((config) => { - setBackendGoogleAvailability( - config.google?.isConfigured ? "available" : "unavailable", - ); - }) - .catch(() => { - loadPromise = undefined; - setBackendGoogleAvailability("unavailable"); - }); - } - - return loadPromise; -}; - -export const useIsGoogleAvailable = (): boolean => { - const isBackendGoogleConfigured = useSyncExternalStore( - subscribeToBackendGoogleAvailability, - getBackendGoogleAvailabilitySnapshot, - getBackendGoogleAvailabilitySnapshot, - ); - - useEffect(() => { - void loadBackendGoogleAvailability(); - }, []); - - return IS_GOOGLE_AUTH_CONFIGURED && isBackendGoogleConfigured; -}; - -export const resetGoogleAvailabilityForTests = () => { - backendGoogleAvailability = "unknown"; - loadPromise = undefined; - emit(); -}; +export const { resetGoogleAvailabilityForTests, useIsGoogleAvailable } = + googleAvailability; diff --git a/packages/web/src/auth/google/util/google.auth.util.factory.ts b/packages/web/src/auth/google/util/google.auth.util.factory.ts new file mode 100644 index 000000000..b5f2c6bbd --- /dev/null +++ b/packages/web/src/auth/google/util/google.auth.util.factory.ts @@ -0,0 +1,111 @@ +import { type Id, type toast } from "react-toastify"; +import { Origin } from "@core/constants/core.constants"; +import { Status } from "@core/errors/status.codes"; +import { type ApiError } from "@web/common/apis/api.types"; +import { + GOOGLE_REVOKED_TOAST_ID, + toastDefaultOptions, +} from "@web/common/constants/toast.constants"; +import { authSlice } from "@web/ducks/auth/slices/auth.slice"; +import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; +import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; +import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; +import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; + +export interface SyncLocalEventsResult { + syncedCount: number; + success: boolean; + error?: Error; +} + +export const LOCAL_EVENTS_SYNC_ERROR_MESSAGE = + "We could not sync your local events. Your changes are still saved on this device."; +export const LOCAL_EVENTS_SYNC_SESSION_EXPIRED_MESSAGE = + "Your session expired before Compass could save your local events. Sign in again to continue. Your changes are still saved on this device."; + +type GoogleAuthUtilDependencies = { + closeStream: () => void; + dispatch: (action: unknown) => unknown; + isToastActive: (toastId: Id) => boolean; + markGoogleAsRevoked: () => void; + openStream: () => void; + syncLocalEventsToCloud: () => Promise; + toastError: typeof toast.error; +}; + +const getApiErrorStatus = (error: Error | undefined): number | undefined => + (error as ApiError | undefined)?.response?.status; + +export function createGoogleAuthUtil({ + closeStream, + dispatch, + isToastActive, + markGoogleAsRevoked, + openStream, + syncLocalEventsToCloud, + toastError, +}: GoogleAuthUtilDependencies) { + const handleGoogleRevoked = () => { + if (!isToastActive(GOOGLE_REVOKED_TOAST_ID)) { + toastError("Google access revoked. Your Google data has been removed.", { + toastId: GOOGLE_REVOKED_TOAST_ID, + autoClose: false, + }); + } + + markGoogleAsRevoked(); + + dispatch(authSlice.actions.resetAuth()); + dispatch(userMetadataSlice.actions.clear(undefined)); + + dispatch( + eventsEntitiesSlice.actions.removeEventsByOrigin({ + origins: [Origin.GOOGLE, Origin.GOOGLE_IMPORT], + }), + ); + dispatch( + triggerFetch({ reason: Sync_AsyncStateContextReason.GOOGLE_REVOKED }), + ); + + closeStream(); + openStream(); + }; + + const showLocalEventsSyncFailure = (error: Error | undefined) => { + const status = getApiErrorStatus(error); + const message = + status === Status.UNAUTHORIZED + ? LOCAL_EVENTS_SYNC_SESSION_EXPIRED_MESSAGE + : LOCAL_EVENTS_SYNC_ERROR_MESSAGE; + + toastError(message, toastDefaultOptions); + console.error(error); + }; + + const syncLocalEvents = async (): Promise => { + try { + const syncedCount = await syncLocalEventsToCloud(); + return { syncedCount, success: true }; + } catch (error) { + return { syncedCount: 0, success: false, error: error as Error }; + } + }; + + const syncPendingLocalEvents = async (): Promise => { + const syncResult = await syncLocalEvents(); + + if (!syncResult.success) { + showLocalEventsSyncFailure(syncResult.error); + return false; + } + + return true; + }; + + return { + handleGoogleRevoked, + showLocalEventsSyncFailure, + syncLocalEvents, + syncPendingLocalEvents, + }; +} diff --git a/packages/web/src/auth/google/util/google.auth.util.test.ts b/packages/web/src/auth/google/util/google.auth.util.test.ts index c4ba8b2ac..eef40df0b 100644 --- a/packages/web/src/auth/google/util/google.auth.util.test.ts +++ b/packages/web/src/auth/google/util/google.auth.util.test.ts @@ -1,9 +1,14 @@ import { clearGoogleRevokedState, isGoogleRevoked, + markGoogleAsRevoked, } from "@web/auth/google/state/google.auth.state"; import { - afterAll, + createGoogleAuthUtil, + LOCAL_EVENTS_SYNC_ERROR_MESSAGE, + LOCAL_EVENTS_SYNC_SESSION_EXPIRED_MESSAGE, +} from "./google.auth.util.factory"; +import { afterEach, beforeEach, describe, @@ -13,65 +18,36 @@ import { spyOn, } from "bun:test"; -// Mock definitions -const mockAuthApi = { - loginOrSignup: mock(), - connectGoogle: mock(), -}; - const mockSyncLocalEventsToCloud = mock(); - -const mockToast = { - error: mock(), - isActive: mock(() => false), -}; - -const mockStore = { - dispatch: mock(), -}; - -const mockSse = { - closeStream: mock(), - openStream: mock(), - getStream: mock(() => null), -}; - -// Apply mocks -mock.module("@web/common/apis/auth.api", () => ({ - AuthApi: mockAuthApi, -})); -mock.module("@web/common/utils/sync/local-event-sync.util", () => ({ +const mockToastError = mock(); +const mockIsToastActive = mock(() => false); +const mockDispatch = mock(); +const mockCloseStream = mock(); +const mockOpenStream = mock(); + +const googleAuthUtil = createGoogleAuthUtil({ + closeStream: mockCloseStream, + dispatch: mockDispatch, + isToastActive: mockIsToastActive, + markGoogleAsRevoked, + openStream: mockOpenStream, syncLocalEventsToCloud: mockSyncLocalEventsToCloud, -})); -mock.module("react-toastify", () => ({ - ToastContainer: () => null, - toast: mockToast, -})); -mock.module("@web/store", () => ({ - store: mockStore, -})); -mock.module("@web/sse/client/sse.client", () => mockSse); - -// Import the module under test after mocking + toastError: mockToastError, +}); + const { handleGoogleRevoked, syncLocalEvents, syncPendingLocalEvents } = - require("./google.auth.util") as typeof import("./google.auth.util"); -const { - LOCAL_EVENTS_SYNC_ERROR_MESSAGE, - LOCAL_EVENTS_SYNC_SESSION_EXPIRED_MESSAGE, -} = require("./google.auth.util") as typeof import("./google.auth.util"); + googleAuthUtil; describe("google-auth.util", () => { beforeEach(() => { - mockAuthApi.loginOrSignup.mockClear(); mockSyncLocalEventsToCloud.mockClear(); - mockToast.error.mockClear(); - mockToast.isActive.mockClear(); - mockStore.dispatch.mockClear(); - mockSse.closeStream.mockClear(); - mockSse.openStream.mockClear(); - mockSse.getStream.mockClear(); - - // Clear in-memory revoked state between tests + mockToastError.mockClear(); + mockIsToastActive.mockClear(); + mockIsToastActive.mockReturnValue(false); + mockDispatch.mockClear(); + mockCloseStream.mockClear(); + mockOpenStream.mockClear(); + clearGoogleRevokedState(); }); @@ -83,17 +59,30 @@ describe("google-auth.util", () => { it("returns syncedCount and success when sync succeeds", async () => { mockSyncLocalEventsToCloud.mockResolvedValue(5); - await syncLocalEvents(); + await expect(syncLocalEvents()).resolves.toEqual({ + syncedCount: 5, + success: true, + }); }); it("returns 0 count when no events to sync", async () => { mockSyncLocalEventsToCloud.mockResolvedValue(0); - await syncLocalEvents(); + await expect(syncLocalEvents()).resolves.toEqual({ + syncedCount: 0, + success: true, + }); }); it("returns error when sync fails", async () => { - expect(mockSyncLocalEventsToCloud).toBeDefined(); + const error = new Error("Network failed"); + mockSyncLocalEventsToCloud.mockRejectedValue(error); + + await expect(syncLocalEvents()).resolves.toEqual({ + error, + syncedCount: 0, + success: false, + }); }); }); @@ -111,13 +100,13 @@ describe("google-auth.util", () => { it("returns true when sync succeeds with events", async () => { mockSyncLocalEventsToCloud.mockResolvedValue(3); - await syncPendingLocalEvents(); + await expect(syncPendingLocalEvents()).resolves.toBe(true); }); it("returns true when syncedCount is zero", async () => { mockSyncLocalEventsToCloud.mockResolvedValue(0); - await syncPendingLocalEvents(); + await expect(syncPendingLocalEvents()).resolves.toBe(true); }); it("shows toast and returns false on sync failure", async () => { @@ -126,7 +115,7 @@ describe("google-auth.util", () => { await expect(syncPendingLocalEvents()).resolves.toBe(false); - expect(mockToast.error).toHaveBeenCalledWith( + expect(mockToastError).toHaveBeenCalledWith( LOCAL_EVENTS_SYNC_ERROR_MESSAGE, expect.any(Object), ); @@ -141,7 +130,7 @@ describe("google-auth.util", () => { await expect(syncPendingLocalEvents()).resolves.toBe(false); - expect(mockToast.error).toHaveBeenCalledWith( + expect(mockToastError).toHaveBeenCalledWith( LOCAL_EVENTS_SYNC_SESSION_EXPIRED_MESSAGE, expect.any(Object), ); @@ -150,49 +139,40 @@ describe("google-auth.util", () => { }); describe("handleGoogleRevoked", () => { - beforeEach(() => { - mockToast.isActive.mockReturnValue(false); - }); - it("shows toast with GOOGLE_REVOKED_TOAST_ID when not already active", () => { handleGoogleRevoked(); - expect(mockToast.error).toBeDefined(); - }); - it("dispatches removeEventsByOrigin for Google origins", () => { - handleGoogleRevoked(); - expect(mockStore.dispatch).toBeDefined(); + expect(mockToastError).toHaveBeenCalledWith( + "Google access revoked. Your Google data has been removed.", + expect.objectContaining({ autoClose: false }), + ); }); - it("clears auth and user metadata state", () => { + it("dispatches the Google revocation state changes", () => { handleGoogleRevoked(); - expect(mockStore.dispatch).toBeDefined(); - }); - it("dispatches triggerFetch with GOOGLE_REVOKED reason", () => { - handleGoogleRevoked(); - expect(mockStore.dispatch).toBeDefined(); + expect(mockDispatch).toHaveBeenCalledTimes(4); }); it("reconnects SSE stream so the client gets a fresh session after revocation", () => { handleGoogleRevoked(); - expect(mockSse.closeStream).toBeDefined(); + + expect(mockCloseStream).toHaveBeenCalledTimes(1); + expect(mockOpenStream).toHaveBeenCalledTimes(1); }); it("marks Google as revoked in session state", () => { handleGoogleRevoked(); - expect(isGoogleRevoked()).toBeDefined(); + + expect(isGoogleRevoked()).toBe(true); }); - it("does not show toast when one is already active (idempotent)", () => { - mockToast.isActive.mockReturnValue(true); + it("does not show toast when one is already active", () => { + mockIsToastActive.mockReturnValue(true); handleGoogleRevoked(); - expect(mockToast.isActive).toBeDefined(); + + expect(mockToastError).not.toHaveBeenCalled(); }); }); }); - -afterAll(() => { - mock.restore(); -}); diff --git a/packages/web/src/auth/google/util/google.auth.util.ts b/packages/web/src/auth/google/util/google.auth.util.ts index 2293e682f..bfb2e17a6 100644 --- a/packages/web/src/auth/google/util/google.auth.util.ts +++ b/packages/web/src/auth/google/util/google.auth.util.ts @@ -1,100 +1,38 @@ import { toast } from "react-toastify"; -import { Origin } from "@core/constants/core.constants"; -import { Status } from "@core/errors/status.codes"; import { markGoogleAsRevoked } from "@web/auth/google/state/google.auth.state"; -import { type ApiError } from "@web/common/apis/api.types"; -import { - GOOGLE_REVOKED_TOAST_ID, - toastDefaultOptions, -} from "@web/common/constants/toast.constants"; import { syncLocalEventsToCloud } from "@web/common/utils/sync/local-event-sync.util"; -import { authSlice } from "@web/ducks/auth/slices/auth.slice"; -import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; -import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; -import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; -import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; import { closeStream, openStream } from "@web/sse/client/sse.client"; import { store } from "@web/store"; - -export interface SyncLocalEventsResult { - syncedCount: number; - success: boolean; - error?: Error; -} - -export const LOCAL_EVENTS_SYNC_ERROR_MESSAGE = - "We could not sync your local events. Your changes are still saved on this device."; -export const LOCAL_EVENTS_SYNC_SESSION_EXPIRED_MESSAGE = - "Your session expired before Compass could save your local events. Sign in again to continue. Your changes are still saved on this device."; - -const getApiErrorStatus = (error: Error | undefined): number | undefined => - (error as ApiError | undefined)?.response?.status; - -/** Idempotent handler for Google access revocation. Safe to call from both API interceptor and socket handler. */ -export const handleGoogleRevoked = () => { - if (!toast.isActive(GOOGLE_REVOKED_TOAST_ID)) { - toast.error("Google access revoked. Your Google data has been removed.", { - toastId: GOOGLE_REVOKED_TOAST_ID, - autoClose: false, - }); - } - - // Mark Google as revoked so the app uses LocalEventRepository - // until user re-authenticates - markGoogleAsRevoked(); - - store.dispatch(authSlice.actions.resetAuth()); - store.dispatch(userMetadataSlice.actions.clear(undefined)); - - store.dispatch( - eventsEntitiesSlice.actions.removeEventsByOrigin({ - origins: [Origin.GOOGLE, Origin.GOOGLE_IMPORT], - }), - ); - store.dispatch( - triggerFetch({ reason: Sync_AsyncStateContextReason.GOOGLE_REVOKED }), - ); - - // Always reconnect so the stream gets a fresh session; the backend has pruned - // Google data and the current connection may carry stale auth state. - closeStream(); - openStream(); -}; - -export const showLocalEventsSyncFailure = (error: Error | undefined) => { - const status = getApiErrorStatus(error); - const message = - status === Status.UNAUTHORIZED - ? LOCAL_EVENTS_SYNC_SESSION_EXPIRED_MESSAGE - : LOCAL_EVENTS_SYNC_ERROR_MESSAGE; - - toast.error(message, toastDefaultOptions); - console.error(error); +import { + createGoogleAuthUtil, + LOCAL_EVENTS_SYNC_ERROR_MESSAGE, + LOCAL_EVENTS_SYNC_SESSION_EXPIRED_MESSAGE, + type SyncLocalEventsResult, +} from "./google.auth.util.factory"; + +const googleAuthUtil = createGoogleAuthUtil({ + closeStream, + dispatch: store.dispatch, + isToastActive: toast.isActive, + markGoogleAsRevoked, + openStream, + syncLocalEventsToCloud: () => syncLocalEventsToCloud(), + toastError: toast.error, +}); + +const { + handleGoogleRevoked, + showLocalEventsSyncFailure, + syncLocalEvents, + syncPendingLocalEvents, +} = googleAuthUtil; + +export { + handleGoogleRevoked, + LOCAL_EVENTS_SYNC_ERROR_MESSAGE, + LOCAL_EVENTS_SYNC_SESSION_EXPIRED_MESSAGE, + type SyncLocalEventsResult, + showLocalEventsSyncFailure, + syncLocalEvents, + syncPendingLocalEvents, }; - -/** - * Sync local events to the cloud. - */ -export async function syncLocalEvents(): Promise { - try { - const syncedCount = await syncLocalEventsToCloud(); - return { syncedCount, success: true }; - } catch (error) { - return { syncedCount: 0, success: false, error: error as Error }; - } -} - -/** - * Runs {@link syncLocalEvents}, surfaces failures with a toast, and returns - * whether sync succeeded. - */ -export async function syncPendingLocalEvents(): Promise { - const syncResult = await syncLocalEvents(); - - if (!syncResult.success) { - showLocalEventsSyncFailure(syncResult.error); - return false; - } - - return true; -} diff --git a/packages/web/src/common/apis/base/base.api.test.ts b/packages/web/src/common/apis/base/base.api.test.ts index ae982afd9..cf2b43afb 100644 --- a/packages/web/src/common/apis/base/base.api.test.ts +++ b/packages/web/src/common/apis/base/base.api.test.ts @@ -1,12 +1,11 @@ +import { type ApiRequestConfig } from "../api.types"; import { isBackendUnavailable, markBackendUnavailable, resetBackendAvailabilityForTests, } from "../util/backend-unavailable-error.util"; import { BaseApi } from "./base.api"; -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; - -const originalFetch = globalThis.fetch; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; describe("BaseApi backend availability", () => { beforeEach(() => { @@ -15,15 +14,14 @@ describe("BaseApi backend availability", () => { }); afterEach(() => { - globalThis.fetch = originalFetch; BaseApi.defaults.adapter = undefined; resetBackendAvailabilityForTests(); }); it("marks the backend unavailable when fetch cannot reach it", async () => { - globalThis.fetch = mock(() => - Promise.reject(new TypeError("Failed to fetch")), - ) as unknown as typeof fetch; + BaseApi.defaults.adapter = async () => { + throw new TypeError("Failed to fetch"); + }; await expect(BaseApi.get("/event")).rejects.toMatchObject({ name: "ApiError", @@ -32,11 +30,29 @@ describe("BaseApi backend availability", () => { expect(isBackendUnavailable()).toBe(true); }); + it("does not mark the backend unavailable for non-network request failures", async () => { + BaseApi.defaults.adapter = async () => { + throw new Error("Unexpected adapter failure"); + }; + + await expect(BaseApi.get("/event")).rejects.toMatchObject({ + name: "ApiError", + }); + + expect(isBackendUnavailable()).toBe(false); + }); + it("marks the backend available when a response arrives", async () => { markBackendUnavailable(); - globalThis.fetch = mock(() => - Promise.resolve(new Response("{}", { status: 200 })), - ) as unknown as typeof fetch; + BaseApi.defaults.adapter = async ( + config: ApiRequestConfig & { body?: unknown }, + ) => ({ + config, + data: {} as T, + headers: new Headers(), + status: 200, + statusText: "OK", + }); await BaseApi.get("/config"); diff --git a/packages/web/src/common/hooks/useAuthCmdItems.test.ts b/packages/web/src/common/hooks/useAuthCmdItems.test.ts index 5556b89dc..d95f561e8 100644 --- a/packages/web/src/common/hooks/useAuthCmdItems.test.ts +++ b/packages/web/src/common/hooks/useAuthCmdItems.test.ts @@ -1,6 +1,6 @@ import { renderHook } from "@testing-library/react"; import { act, type MouseEvent } from "react"; -import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; const mockOpenModal = mock(); const mockUseAuthModal = mock(); @@ -90,7 +90,3 @@ describe("useAuthCmdItems", () => { expect(mockOpenModal).toHaveBeenNthCalledWith(2, "login"); }); }); - -afterAll(() => { - mock.restore(); -}); diff --git a/packages/web/src/common/hooks/useLogoutCmdItems.test.ts b/packages/web/src/common/hooks/useLogoutCmdItems.test.ts index 1ba06b69a..790ae3fa8 100644 --- a/packages/web/src/common/hooks/useLogoutCmdItems.test.ts +++ b/packages/web/src/common/hooks/useLogoutCmdItems.test.ts @@ -1,6 +1,6 @@ import { renderHook } from "@testing-library/react"; import { act, type MouseEvent } from "react"; -import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; const mockOpenLogoutConfirmation = mock(); const mockUseLogoutConfirmation = mock(); @@ -55,7 +55,3 @@ describe("useLogoutCmdItems", () => { expect(mockOpenLogoutConfirmation).toHaveBeenCalledTimes(1); }); }); - -afterAll(() => { - mock.restore(); -}); diff --git a/packages/web/src/common/hooks/useVersionCheck.test.ts b/packages/web/src/common/hooks/useVersionCheck.test.ts index 692c2a24f..5015230d5 100644 --- a/packages/web/src/common/hooks/useVersionCheck.test.ts +++ b/packages/web/src/common/hooks/useVersionCheck.test.ts @@ -1,7 +1,6 @@ import { renderHook, waitFor } from "@testing-library/react"; import { act } from "react"; import { - afterAll, afterEach, beforeEach, describe, @@ -14,6 +13,7 @@ import { let mockIsDev = false; const fetchMock = mock(); +const originalFetch = globalThis.fetch; const MIN_HIDDEN_DURATION_MS = 30_000; const BACKUP_CHECK_INTERVAL_MS = 5 * 60 * 1000; @@ -75,6 +75,7 @@ describe("useVersionCheck", () => { }); afterEach(() => { + globalThis.fetch = originalFetch; setSystemTime(); setIntervalSpy.mockRestore(); }); @@ -289,7 +290,3 @@ describe("useVersionCheck", () => { }); }); }); - -afterAll(() => { - mock.restore(); -}); diff --git a/packages/web/src/common/repositories/event/event.repository.factory.ts b/packages/web/src/common/repositories/event/event.repository.factory.ts new file mode 100644 index 000000000..cbfc96509 --- /dev/null +++ b/packages/web/src/common/repositories/event/event.repository.factory.ts @@ -0,0 +1,37 @@ +import { type EventRepository } from "./event.repository.interface"; + +type EventRepositoryDependencies = { + createLocalEventRepository: () => EventRepository; + createRemoteEventRepository: () => EventRepository; + hasUserEverAuthenticated: () => boolean; + isBackendUnavailable: () => boolean; + isGoogleRevoked: () => boolean; +}; + +export function createGetEventRepository({ + createLocalEventRepository, + createRemoteEventRepository, + hasUserEverAuthenticated, + isBackendUnavailable, + isGoogleRevoked, +}: EventRepositoryDependencies) { + return function getEventRepository(sessionExists: boolean): EventRepository { + if (isGoogleRevoked()) { + return createLocalEventRepository(); + } + + if (isBackendUnavailable()) { + return createLocalEventRepository(); + } + + if (hasUserEverAuthenticated()) { + return createRemoteEventRepository(); + } + + if (sessionExists) { + return createRemoteEventRepository(); + } + + return createLocalEventRepository(); + }; +} diff --git a/packages/web/src/common/repositories/event/event.repository.util.test.ts b/packages/web/src/common/repositories/event/event.repository.util.test.ts index 3a3a8b229..2640d515a 100644 --- a/packages/web/src/common/repositories/event/event.repository.util.test.ts +++ b/packages/web/src/common/repositories/event/event.repository.util.test.ts @@ -1,28 +1,25 @@ -import * as authState from "@web/auth/compass/state/auth.state.util"; -import * as googleAuthState from "@web/auth/google/state/google.auth.state"; -import { - markBackendUnavailable, - resetBackendAvailabilityForTests, -} from "@web/common/apis/util/backend-unavailable-error.util"; -import { beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { createGetEventRepository } from "./event.repository.factory"; +import { LocalEventRepository } from "./local.event.repository"; +import { RemoteEventRepository } from "./remote.event.repository"; +import { beforeEach, describe, expect, it } from "bun:test"; -const hasUserEverAuthenticatedSpy = spyOn( - authState, - "hasUserEverAuthenticated", -); -const isGoogleRevokedSpy = spyOn(googleAuthState, "isGoogleRevoked"); +describe("getEventRepository", () => { + let hasUserEverAuthenticated = false; + let isBackendUnavailable = false; + let isGoogleRevoked = false; -const { LocalEventRepository } = await import("./local.event.repository"); -const { RemoteEventRepository } = await import("./remote.event.repository"); -const { getEventRepository } = await import("./event.repository.util"); + const getEventRepository = createGetEventRepository({ + createLocalEventRepository: () => new LocalEventRepository(), + createRemoteEventRepository: () => new RemoteEventRepository(), + hasUserEverAuthenticated: () => hasUserEverAuthenticated, + isBackendUnavailable: () => isBackendUnavailable, + isGoogleRevoked: () => isGoogleRevoked, + }); -describe("getEventRepository", () => { beforeEach(() => { - hasUserEverAuthenticatedSpy.mockReset(); - hasUserEverAuthenticatedSpy.mockReturnValue(false); - isGoogleRevokedSpy.mockReset(); - isGoogleRevokedSpy.mockReturnValue(false); - resetBackendAvailabilityForTests(); + hasUserEverAuthenticated = false; + isBackendUnavailable = false; + isGoogleRevoked = false; }); it("uses remote storage when a session exists", () => { @@ -34,33 +31,33 @@ describe("getEventRepository", () => { }); it("uses remote storage when a returning user has no active session", () => { - hasUserEverAuthenticatedSpy.mockReturnValue(true); + hasUserEverAuthenticated = true; expect(getEventRepository(false)).toBeInstanceOf(RemoteEventRepository); }); it("uses local storage when Google disconnected Compass", () => { - isGoogleRevokedSpy.mockReturnValue(true); + isGoogleRevoked = true; expect(getEventRepository(true)).toBeInstanceOf(LocalEventRepository); }); it("uses local storage when Google disconnected Compass for a returning user", () => { - hasUserEverAuthenticatedSpy.mockReturnValue(true); - isGoogleRevokedSpy.mockReturnValue(true); + hasUserEverAuthenticated = true; + isGoogleRevoked = true; expect(getEventRepository(false)).toBeInstanceOf(LocalEventRepository); }); it("uses local storage for a returning user when the backend is unavailable", () => { - hasUserEverAuthenticatedSpy.mockReturnValue(true); - markBackendUnavailable(); + hasUserEverAuthenticated = true; + isBackendUnavailable = true; expect(getEventRepository(false)).toBeInstanceOf(LocalEventRepository); }); it("uses local storage for an active session when the backend is unavailable", () => { - markBackendUnavailable(); + isBackendUnavailable = true; expect(getEventRepository(true)).toBeInstanceOf(LocalEventRepository); }); diff --git a/packages/web/src/common/repositories/event/event.repository.util.ts b/packages/web/src/common/repositories/event/event.repository.util.ts index fe078ebfe..637879552 100644 --- a/packages/web/src/common/repositories/event/event.repository.util.ts +++ b/packages/web/src/common/repositories/event/event.repository.util.ts @@ -9,7 +9,7 @@ import { hasUserEverAuthenticated } from "@web/auth/compass/state/auth.state.util"; import { isGoogleRevoked } from "@web/auth/google/state/google.auth.state"; import { isBackendUnavailable } from "@web/common/apis/util/backend-unavailable-error.util"; -import { type EventRepository } from "./event.repository.interface"; +import { createGetEventRepository } from "./event.repository.factory"; import { LocalEventRepository } from "./local.event.repository"; import { RemoteEventRepository } from "./remote.event.repository"; @@ -33,23 +33,10 @@ import { RemoteEventRepository } from "./remote.event.repository"; * * @param sessionExists - Whether a session currently exists (from session.doesSessionExist()) */ -export function getEventRepository(sessionExists: boolean): EventRepository { - // If Google disconnected Compass, use local storage until user re-authenticates - if (isGoogleRevoked()) { - return new LocalEventRepository(); - } - - if (isBackendUnavailable()) { - return new LocalEventRepository(); - } - - if (hasUserEverAuthenticated()) { - return new RemoteEventRepository(); - } - - if (sessionExists) { - return new RemoteEventRepository(); - } - - return new LocalEventRepository(); -} +export const getEventRepository = createGetEventRepository({ + createLocalEventRepository: () => new LocalEventRepository(), + createRemoteEventRepository: () => new RemoteEventRepository(), + hasUserEverAuthenticated, + isBackendUnavailable, + isGoogleRevoked, +}); diff --git a/packages/web/src/common/repositories/event/remote.event.repository.test.ts b/packages/web/src/common/repositories/event/remote.event.repository.test.ts index 3f0d6526b..d317b936e 100644 --- a/packages/web/src/common/repositories/event/remote.event.repository.test.ts +++ b/packages/web/src/common/repositories/event/remote.event.repository.test.ts @@ -8,7 +8,7 @@ import { isBackendUnavailable, resetBackendAvailabilityForTests, } from "@web/common/apis/util/backend-unavailable-error.util"; -import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; const mockCreate = mock(); const mockGet = mock(); @@ -274,7 +274,3 @@ describe("RemoteEventRepository", () => { }); }); }); - -afterAll(() => { - mock.restore(); -}); diff --git a/packages/web/src/common/utils/app-init.util.test.ts b/packages/web/src/common/utils/app-init.util.test.ts index 84e207ca7..f4d68372c 100644 --- a/packages/web/src/common/utils/app-init.util.test.ts +++ b/packages/web/src/common/utils/app-init.util.test.ts @@ -1,6 +1,5 @@ import { DatabaseInitError } from "@web/common/utils/storage/db-errors.util"; import { - afterAll, afterEach, beforeEach, describe, @@ -180,7 +179,3 @@ describe("app-init.util", () => { }); }); }); - -afterAll(() => { - mock.restore(); -}); diff --git a/packages/web/src/common/utils/draft/draft.util.test.ts b/packages/web/src/common/utils/draft/draft.util.test.ts index a3fc44432..edc3be22d 100644 --- a/packages/web/src/common/utils/draft/draft.util.test.ts +++ b/packages/web/src/common/utils/draft/draft.util.test.ts @@ -107,5 +107,4 @@ describe("shortcut draft creation", () => { afterAll(() => { setSystemTime(); - mock.restore(); }); diff --git a/packages/web/src/common/utils/draft/someday.draft.util.test.ts b/packages/web/src/common/utils/draft/someday.draft.util.test.ts index c466b6ae8..2821fb331 100644 --- a/packages/web/src/common/utils/draft/someday.draft.util.test.ts +++ b/packages/web/src/common/utils/draft/someday.draft.util.test.ts @@ -2,21 +2,10 @@ import { Categories_Event } from "@core/types/event.types"; import dayjs from "@core/util/date/dayjs"; import { draftSlice } from "@web/ducks/events/slices/draft.slice"; import { type Activity_DraftEvent } from "@web/ducks/events/slices/draft.slice.types"; -import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; -// Mock assembleDefaultEvent since it makes external calls -const assembleDefaultEvent = mock(async (_, startDate, endDate) => ({ - _id: "mock-id", - user: "mock-user", - title: "", - startDate, - endDate, - isAllDay: false, - isSomeday: true, -})); - -mock.module("../event/event.util", () => ({ - assembleDefaultEvent, +mock.module("@web/auth/compass/session/session.util", () => ({ + getUserId: mock().mockResolvedValue("mock-user"), })); const { createSomedayDraft } = @@ -27,7 +16,6 @@ describe("createSomedayDraft", () => { const mockActivity: Activity_DraftEvent = "sidebarClick"; beforeEach(() => { - assembleDefaultEvent.mockClear(); mockDispatch.mockClear(); }); @@ -46,25 +34,17 @@ describe("createSomedayDraft", () => { const expectedStart = "2024-03-10"; const expectedEnd = "2024-03-16"; - expect(assembleDefaultEvent).toHaveBeenCalledWith( - Categories_Event.SOMEDAY_WEEK, - expectedStart, - expectedEnd, - ); - expect(mockDispatch).toHaveBeenCalledWith( draftSlice.actions.start({ activity: mockActivity, eventType: Categories_Event.SOMEDAY_WEEK, - event: { - _id: "mock-id", + event: expect.objectContaining({ user: "mock-user", - title: "", startDate: expectedStart, endDate: expectedEnd, isAllDay: false, isSomeday: true, - }, + }), }), ); }); @@ -84,30 +64,18 @@ describe("createSomedayDraft", () => { const expectedStart = "2024-02-01"; const expectedEnd = "2024-02-29"; - expect(assembleDefaultEvent).toHaveBeenCalledWith( - Categories_Event.SOMEDAY_MONTH, - expectedStart, - expectedEnd, - ); - expect(mockDispatch).toHaveBeenCalledWith( draftSlice.actions.start({ activity: mockActivity, eventType: Categories_Event.SOMEDAY_MONTH, - event: { - _id: "mock-id", + event: expect.objectContaining({ user: "mock-user", - title: "", startDate: expectedStart, endDate: expectedEnd, isAllDay: false, isSomeday: true, - }, + }), }), ); }); }); - -afterAll(() => { - mock.restore(); -}); diff --git a/packages/web/src/common/utils/form/form.util.test.ts b/packages/web/src/common/utils/form/form.util.test.ts index 9ba279a46..502f3ebd7 100644 --- a/packages/web/src/common/utils/form/form.util.test.ts +++ b/packages/web/src/common/utils/form/form.util.test.ts @@ -9,28 +9,40 @@ import { isEditableKeyboardTarget, isEventFormOpen, } from "./form.util"; -import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from "bun:test"; -// Mock DOM methods const mockGetElementsByName = mock(); const mockGetElementById = mock(); -// Mock document -Object.defineProperty(document, "getElementsByName", { - value: mockGetElementsByName, - writable: true, -}); - -Object.defineProperty(document, "getElementById", { - value: mockGetElementById, - writable: true, -}); - describe("form.util", () => { + let getElementByIdSpy: ReturnType; + let getElementsByNameSpy: ReturnType; + beforeEach(() => { - // Reset all mocks before each test mockGetElementById.mockClear(); mockGetElementsByName.mockClear(); + getElementByIdSpy = spyOn(document, "getElementById").mockImplementation( + mockGetElementById as typeof document.getElementById, + ); + getElementsByNameSpy = spyOn( + document, + "getElementsByName", + ).mockImplementation( + mockGetElementsByName as typeof document.getElementsByName, + ); + }); + + afterEach(() => { + getElementByIdSpy.mockRestore(); + getElementsByNameSpy.mockRestore(); }); describe("isEventFormOpen", () => { 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 b7280bf6e..163fe4fe0 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 @@ -1,7 +1,7 @@ import { Origin, Priorities } from "@core/constants/core.constants"; import { type Event_Core } from "@core/types/event.types"; -import { EventApi } from "@web/ducks/events/event.api"; import { markLocalDemoEvent } from "../../storage/types/local-event.types"; +import { createSyncLocalEventsToCloud } from "./local-event-sync.util"; import { beforeEach, describe, expect, it, mock } from "bun:test"; const ensureStorageReady = mock(); @@ -9,20 +9,14 @@ const getAllEvents = mock(); const clearAllEvents = mock(); const create = mock(); -mock.module("@web/common/storage/adapter/adapter", () => ({ +const syncLocalEventsToCloud = createSyncLocalEventsToCloud({ + createEvents: create, ensureStorageReady, getStorageAdapter: () => ({ - getAllEvents, clearAllEvents, + getAllEvents, }), -})); - -mock.module("@web/ducks/events/event.api", () => ({ - EventApi: { create }, -})); - -const { syncLocalEventsToCloud } = - require("./local-event-sync.util") as typeof import("./local-event-sync.util"); +}); const makeEvent = (overrides: Partial = {}): Event_Core => ({ _id: overrides._id ?? "event-1", @@ -52,7 +46,7 @@ describe("syncLocalEventsToCloud", () => { await expect(syncLocalEventsToCloud()).resolves.toBe(1); - expect(EventApi.create).toHaveBeenCalledWith([userEvent]); + expect(create).toHaveBeenCalledWith([userEvent]); expect(clearAllEvents).toHaveBeenCalledTimes(1); }); @@ -63,7 +57,7 @@ describe("syncLocalEventsToCloud", () => { await expect(syncLocalEventsToCloud()).resolves.toBe(0); - expect(EventApi.create).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); expect(clearAllEvents).toHaveBeenCalledTimes(1); }); }); 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 3168c4c21..2f0a08c50 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 @@ -2,30 +2,54 @@ import { ensureStorageReady, getStorageAdapter, } from "@web/common/storage/adapter/adapter"; +import { type StorageAdapter } from "@web/common/storage/adapter/storage.adapter"; import { isLocalDemoEvent, stripLocalOnlyEventFields, } from "@web/common/storage/types/local-event.types"; import { EventApi } from "@web/ducks/events/event.api"; -export async function syncLocalEventsToCloud(): Promise { - await ensureStorageReady(); - const adapter = getStorageAdapter(); - const events = await adapter.getAllEvents(); +type LocalEventSyncStorage = Pick< + StorageAdapter, + "clearAllEvents" | "getAllEvents" +>; - if (events.length === 0) { - return 0; - } +type LocalEventSyncDependencies = { + createEvents: typeof EventApi.create; + ensureStorageReady: typeof ensureStorageReady; + getStorageAdapter: () => LocalEventSyncStorage; +}; - const eventsToSync = events - .filter((event) => !isLocalDemoEvent(event)) - .map(stripLocalOnlyEventFields); +export function createSyncLocalEventsToCloud({ + createEvents, + ensureStorageReady, + getStorageAdapter, +}: LocalEventSyncDependencies) { + return async function syncLocalEventsToCloud(): Promise { + await ensureStorageReady(); + const adapter = getStorageAdapter(); + const events = await adapter.getAllEvents(); + + if (events.length === 0) { + return 0; + } + + const eventsToSync = events + .filter((event) => !isLocalDemoEvent(event)) + .map(stripLocalOnlyEventFields); - if (eventsToSync.length > 0) { - await EventApi.create(eventsToSync); - } + if (eventsToSync.length > 0) { + await createEvents(eventsToSync); + } - await adapter.clearAllEvents(); + await adapter.clearAllEvents(); - return eventsToSync.length; + return eventsToSync.length; + }; } + +export const syncLocalEventsToCloud = createSyncLocalEventsToCloud({ + createEvents: EventApi.create, + ensureStorageReady, + getStorageAdapter, +}); diff --git a/packages/web/src/components/AuthModal/AuthModal.test.tsx b/packages/web/src/components/AuthModal/AuthModal.test.tsx index 978f87511..3f8b170e4 100644 --- a/packages/web/src/components/AuthModal/AuthModal.test.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.test.tsx @@ -12,7 +12,6 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { setTestWindowUrl } from "@web/__tests__/set-test-window-url"; import { - afterAll, afterEach, beforeEach, describe, @@ -909,7 +908,9 @@ const mockWindowLocation = (url: string) => { setTestWindowUrl(url); }; -const originalReplaceState = window.history.replaceState.bind(window.history); +const originalReplaceState = Object.getPrototypeOf( + window.history, +).replaceState.bind(window.history) as typeof window.history.replaceState; const replaceStateSpy = spyOn(window.history, "replaceState"); describe("URL Parameter Support", () => { @@ -1106,7 +1107,3 @@ describe("URL Parameter Support", () => { }); }); }); - -afterAll(() => { - mock.restore(); -}); diff --git a/packages/web/src/components/AuthModal/hooks/useAuthUrlParam.test.tsx b/packages/web/src/components/AuthModal/hooks/useAuthUrlParam.test.tsx index dc166c445..74a452312 100644 --- a/packages/web/src/components/AuthModal/hooks/useAuthUrlParam.test.tsx +++ b/packages/web/src/components/AuthModal/hooks/useAuthUrlParam.test.tsx @@ -1,17 +1,11 @@ import { renderHook } from "@testing-library/react"; import { setTestWindowUrl } from "@web/__tests__/set-test-window-url"; import { useAuthUrlParam } from "./useAuthUrlParam"; -import { - afterAll, - beforeEach, - describe, - expect, - it, - mock, - spyOn, -} from "bun:test"; - -const originalReplaceState = window.history.replaceState.bind(window.history); +import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; + +const originalReplaceState = Object.getPrototypeOf( + window.history, +).replaceState.bind(window.history) as typeof window.history.replaceState; const replaceStateSpy = spyOn(window.history, "replaceState"); describe("useAuthUrlParam", () => { @@ -171,7 +165,3 @@ describe("useAuthUrlParam", () => { }); }); }); - -afterAll(() => { - mock.restore(); -}); diff --git a/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.test.tsx b/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.test.tsx index c51dcf63c..77f7c3abe 100644 --- a/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.test.tsx +++ b/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.test.tsx @@ -1,14 +1,10 @@ import { MemoryRouter, Route, Routes } from "react-router-dom"; -import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { describe, expect, it } from "bun:test"; import "@testing-library/jest-dom"; import { render, screen } from "@testing-library/react"; import { AuthenticatedLayout } from "./AuthenticatedLayout"; describe("AuthenticatedLayout", () => { - beforeEach(() => { - mock.restore(); - }); - it("should render child routes via Outlet", async () => { render( { expect(screen.getByText("Nested Content")).toBeInTheDocument(); }); }); - -afterAll(() => { - mock.restore(); -}); diff --git a/packages/web/src/components/ContextMenu/ContextMenuItems.test.tsx b/packages/web/src/components/ContextMenu/ContextMenuItems.test.tsx index 8050218d0..26b2201a0 100644 --- a/packages/web/src/components/ContextMenu/ContextMenuItems.test.tsx +++ b/packages/web/src/components/ContextMenu/ContextMenuItems.test.tsx @@ -1,6 +1,8 @@ +import { configureStore } from "@reduxjs/toolkit"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { type ReactElement } from "react"; +import { Provider } from "react-redux"; import { ThemeProvider } from "styled-components"; import { createMockStandaloneEvent } from "@core/util/test/ccal.event.factory"; import { @@ -10,7 +12,9 @@ import { import { theme } from "@web/common/styles/theme"; import { type Schema_GridEvent } from "@web/common/types/web.event.types"; import { gridEventDefaultPosition } from "@web/common/utils/event/event.util"; -import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { reducers } from "@web/store/reducers"; +import { DraftContext } from "@web/views/Week/components/Draft/context/DraftContext"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; const mockClose = mock(); const mockOpenForm = mock(); @@ -19,22 +23,6 @@ const mockSetDraft = mock(); const mockSubmit = mock(); const mockOnDelete = mock(); -mock.module("@web/views/Week/components/Draft/context/useDraftContext", () => ({ - useDraftContext: () => ({ - actions: { - openForm: mockOpenForm, - duplicateEvent: mockDuplicateEvent, - submit: mockSubmit, - }, - setters: { - setDraft: mockSetDraft, - }, - confirmation: { - onDelete: mockOnDelete, - }, - }), -})); - mock.module( "@web/components/PlannerSidebar/draft/context/useSidebarContext", () => ({ @@ -71,17 +59,48 @@ const createStateWithPendingEvents = ( let currentState = createStateWithPendingEvents(); currentState.auth.status = "authenticating"; -mock.module("@web/store/store.hooks", () => ({ - useAppDispatch: () => mock(), - useAppSelector: (selector: (state: InitialReduxState) => unknown) => - selector(currentState), -})); - const { ContextMenuItems } = require("./ContextMenuItems") as typeof import("./ContextMenuItems"); -const renderWithTheme = (ui: ReactElement) => - render({ui}); +const renderWithTheme = (ui: ReactElement) => { + const store = configureStore({ + preloadedState: currentState, + reducer: reducers, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + immutableCheck: false, + serializableCheck: false, + thunk: false, + }), + }); + + return render( + + + false), + submit: mockSubmit, + }, + confirmation: { + onDelete: mockOnDelete, + }, + setters: { + setDraft: mockSetDraft, + }, + } as never + } + > + {ui} + + + , + ); +}; describe("ContextMenuItems", () => { beforeEach(() => { @@ -231,7 +250,3 @@ describe("ContextMenuItems", () => { expect(mockClose).toHaveBeenCalled(); }); }); - -afterAll(() => { - mock.restore(); -}); diff --git a/packages/web/src/components/HeaderInfoIcon/HeaderInfoIcon.test.tsx b/packages/web/src/components/HeaderInfoIcon/HeaderInfoIcon.test.tsx index c2c874e86..294e3071c 100644 --- a/packages/web/src/components/HeaderInfoIcon/HeaderInfoIcon.test.tsx +++ b/packages/web/src/components/HeaderInfoIcon/HeaderInfoIcon.test.tsx @@ -1,4 +1,3 @@ -import type React from "react"; import "@testing-library/jest-dom"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -9,7 +8,7 @@ import { } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.types"; import { theme } from "@web/common/styles/theme"; import { type AuthView } from "@web/components/AuthModal/hooks/useAuthModal"; -import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; interface MockConnectGoogleResult { commandAction: GoogleUiConfig["commandAction"]; @@ -97,36 +96,54 @@ mock.module("@web/components/AuthModal/hooks/useAuthModal", () => ({ })); mock.module("@phosphor-icons/react", () => ({ + ArrowCounterClockwise: () =>