Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .cursorrules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,6 @@ export default class Migration implements RunnableMigration<MigrationContext> {
{ batchSize: MONGO_BATCH_SIZE },
);

let migratedCount = 0;

for await (const syncDoc of cursor) {
if ((syncDoc?.google?.events?.length ?? 0) < 1) continue;

Expand Down Expand Up @@ -119,17 +117,11 @@ export default class Migration implements RunnableMigration<MigrationContext> {
]);

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<MigrationContext>): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,7 @@ export default class Migration implements RunnableMigration<MigrationContext> {
}

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}`);
}
Expand Down
120 changes: 52 additions & 68 deletions packages/web/src/auth/context/UserProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -12,53 +13,58 @@ 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<typeof usePostHog>;

const mockToastError = toast.error as jest.MockedFunction<typeof toast.error>;

// 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: () => <div>Loading...</div>,
}));

describe("UserProvider", () => {
const mockIdentify = jest.fn();
const mockIdentify = jest.fn();

function mockPostHogEnabled(overrides?: Partial<PostHog>): 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", () => {
it("should call posthog.identify when PostHog is enabled and user data is available", async () => {
const testUserId = "test-user-123";
const testEmail = "test@example.com";

// Mock PostHog as enabled
mockUsePostHog.mockReturnValue({
identify: mockIdentify,
} as any);

render(
<UserProvider>
<div>Test Child</div>
</UserProvider>,
);

// Wait for async data fetch and PostHog identify to be called
await waitFor(() => {
expect(screen.getByText("Test Child")).toBeInTheDocument();
});
Expand All @@ -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(
<UserProvider>
Expand All @@ -86,7 +90,6 @@ describe("UserProvider", () => {
expect(screen.getByText("Test Child")).toBeInTheDocument();
});

// Verify identify was never called
expect(mockIdentify).not.toHaveBeenCalled();
});

Expand All @@ -100,11 +103,6 @@ describe("UserProvider", () => {
}),
);

// Mock PostHog as enabled
mockUsePostHog.mockReturnValue({
identify: mockIdentify,
} as any);

render(
<UserProvider>
<div>Test Child</div>
Expand All @@ -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();
});

Expand All @@ -130,11 +127,6 @@ describe("UserProvider", () => {
}),
);

// Mock PostHog as enabled
mockUsePostHog.mockReturnValue({
identify: mockIdentify,
} as any);

render(
<UserProvider>
<div>Test Child</div>
Expand All @@ -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(
<UserProvider>
Expand All @@ -169,7 +159,6 @@ describe("UserProvider", () => {
expect(screen.getByText("Test Child")).toBeInTheDocument();
});

// Verify identify was not called
expect(mockIdentify).not.toHaveBeenCalled();
});

Expand All @@ -190,37 +179,34 @@ describe("UserProvider", () => {
}),
);

mockUsePostHog.mockReturnValue({
identify: mockIdentify,
} as any);

render(
<UserProvider>
<div>Test Child Content</div>
</UserProvider>,
);

// 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) => {
return res(ctx.status(Status.UNAUTHORIZED));
}),
);

mockUsePostHog.mockReturnValue({
identify: mockIdentify,
} as any);

render(
<UserProvider>
<div>Test Child</div>
Expand All @@ -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,
Expand All @@ -247,27 +234,27 @@ 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,
});
});
});

describe("Authentication Gating", () => {
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(
<UserProvider>
Expand All @@ -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(
<UserProvider>
Expand Down
19 changes: 11 additions & 8 deletions packages/web/src/common/hooks/useIsMobile.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { act } from "react";
import { renderHook } from "@testing-library/react";
import { useIsMobile } from "./useIsMobile";

Expand Down Expand Up @@ -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);
});
Expand Down
7 changes: 7 additions & 0 deletions packages/web/src/common/storage/adapter/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading