Skip to content
Closed
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
63 changes: 63 additions & 0 deletions packages/web/src/auth/session/SessionProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useContext } from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { syncLocalTaskUsersToAuthenticatedUser } from "@web/common/utils/sync/local-task-user-sync.util";
import { SessionContext, SessionProvider } from "./SessionProvider";

jest.mock("@web/common/utils/sync/local-task-user-sync.util");

function SessionTestHarness() {
const { setAuthenticated } = useContext(SessionContext);

return (
<div>
<button onClick={() => setAuthenticated(true)} type="button">
Set Authenticated
</button>
<button onClick={() => setAuthenticated(false)} type="button">
Set Unauthenticated
</button>
</div>
);
}

describe("SessionProvider", () => {
const mockSyncLocalTaskUsersToAuthenticatedUser =
syncLocalTaskUsersToAuthenticatedUser as jest.MockedFunction<
typeof syncLocalTaskUsersToAuthenticatedUser
>;

beforeEach(() => {
jest.clearAllMocks();
mockSyncLocalTaskUsersToAuthenticatedUser.mockResolvedValue(0);
});

it("does not sync task users when authenticated is false", () => {
render(
<SessionProvider>
<SessionTestHarness />
</SessionProvider>,
);

expect(mockSyncLocalTaskUsersToAuthenticatedUser).not.toHaveBeenCalled();
});

it("syncs task users when authenticated becomes true", async () => {
render(
<SessionProvider>
<SessionTestHarness />
</SessionProvider>,
);

fireEvent.click(screen.getByRole("button", { name: "Set Authenticated" }));

await waitFor(() => {
expect(mockSyncLocalTaskUsersToAuthenticatedUser).toHaveBeenCalledTimes(
1,
);
});

fireEvent.click(
screen.getByRole("button", { name: "Set Unauthenticated" }),
);
});
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for error handling when syncLocalTaskUsersToAuthenticatedUser fails. While the SessionProvider correctly catches and logs errors from the sync function, there should be a test verifying that errors are properly handled and don't crash the component. This would ensure the error handling path is tested and maintainable.

Suggested change
});
});
it("handles errors from syncing task users without crashing", async () => {
const consoleErrorSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});
mockSyncLocalTaskUsersToAuthenticatedUser.mockRejectedValueOnce(
new Error("sync failed"),
);
render(
<SessionProvider>
<SessionTestHarness />
</SessionProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Set Authenticated" }));
await waitFor(() => {
expect(mockSyncLocalTaskUsersToAuthenticatedUser).toHaveBeenCalledTimes(
1,
);
});
// The component should still be rendered and interactive despite the error.
expect(
screen.getByRole("button", { name: "Set Authenticated" }),
).toBeInTheDocument();
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});

Copilot uses AI. Check for mistakes.
});
14 changes: 14 additions & 0 deletions packages/web/src/auth/session/SessionProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { session } from "@web/common/classes/Session";
import { ENV_WEB } from "@web/common/constants/env.constants";
import { ROOT_ROUTES } from "@web/common/constants/routes";
import { markUserAsAuthenticated } from "@web/common/utils/storage/auth-state.util";
import { syncLocalTaskUsersToAuthenticatedUser } from "@web/common/utils/sync/local-task-user-sync.util";
import * as socket from "@web/socket/provider/SocketProvider";
import { CompassSession } from "./session.types";

Expand Down Expand Up @@ -117,6 +118,19 @@ export function SessionProvider({ children }: PropsWithChildren<{}>) {
}
}, []);

useEffect(() => {
if (!authenticated) {
return;
}

void syncLocalTaskUsersToAuthenticatedUser().catch((error) => {
console.error(
"Failed to sync local task users after authentication:",
error,
);
});
}, [authenticated]);

return (
<SessionContext.Provider
value={{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ import {
createTestTask,
createTestTasks,
} from "@web/__tests__/utils/repositories/repository.test.factory";
import { getUserId } from "@web/auth/auth.util";
import { UNAUTHENTICATED_USER } from "@web/common/constants/auth.constants";
import * as storageAdapter from "@web/common/storage/adapter/adapter";
import { LocalTaskRepository } from "./local.task.repository";

// Mock the storage adapter module
jest.mock("@web/common/storage/adapter/adapter");
jest.mock("@web/auth/auth.util");

describe("LocalTaskRepository", () => {
let repository: LocalTaskRepository;
let mockGetUserId: jest.MockedFunction<typeof getUserId>;
let mockAdapter: {
getTasks: jest.Mock;
putTasks: jest.Mock;
Expand All @@ -19,6 +23,7 @@ describe("LocalTaskRepository", () => {
};

beforeEach(() => {
mockGetUserId = getUserId as jest.MockedFunction<typeof getUserId>;
mockAdapter = {
getTasks: jest.fn().mockResolvedValue([]),
putTasks: jest.fn().mockResolvedValue(undefined),
Expand All @@ -33,6 +38,7 @@ describe("LocalTaskRepository", () => {

repository = new LocalTaskRepository();
jest.clearAllMocks();
mockGetUserId.mockResolvedValue(UNAUTHENTICATED_USER);

// Re-mock after clearing
(storageAdapter.getStorageAdapter as jest.Mock).mockReturnValue(
Expand Down Expand Up @@ -108,6 +114,70 @@ describe("LocalTaskRepository", () => {
expect(mockAdapter.putTask).toHaveBeenCalledTimes(1);
expect(mockAdapter.putTasks).not.toHaveBeenCalled();
});

it("rewrites placeholder task users when authenticated", async () => {
const dateKey = "2024-01-01";
mockGetUserId.mockResolvedValue("mongo-user-id");
const tasks = [
createTestTask({
_id: "task-1",
title: "Task One",
user: UNAUTHENTICATED_USER,
}),
];

await repository.save(dateKey, tasks);

expect(mockAdapter.putTasks).toHaveBeenCalledWith(
dateKey,
expect.arrayContaining([
expect.objectContaining({
_id: "task-1",
user: "mongo-user-id",
}),
]),
);
});

it("does not rewrite non-placeholder task users", async () => {
const dateKey = "2024-01-01";
mockGetUserId.mockResolvedValue("mongo-user-id");
const task = createTestTask({
_id: "task-1",
title: "Task One",
user: "already-authenticated-user",
});

await repository.save(dateKey, task);

expect(mockAdapter.putTask).toHaveBeenCalledWith(
dateKey,
expect.objectContaining({
_id: "task-1",
user: "already-authenticated-user",
}),
);
});

it("keeps placeholder task users when unauthenticated", async () => {
const dateKey = "2024-01-01";
mockGetUserId.mockResolvedValue(UNAUTHENTICATED_USER);
const task = createTestTask({
_id: "task-1",
title: "Task One",
user: UNAUTHENTICATED_USER,
});

await repository.save(dateKey, task);

expect(mockAdapter.putTask).toHaveBeenCalledWith(
dateKey,
expect.objectContaining({
_id: "task-1",
user: UNAUTHENTICATED_USER,
}),
);
});
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for error handling when getUserId fails during save. While the normalizeUnauthenticatedUsers method correctly catches and logs errors from getUserId, there should be a test verifying this error path to ensure tasks are still saved with their original user field when getUserId fails.

Suggested change
});
});
it("keeps original task user when getUserId fails", async () => {
const dateKey = "2024-01-01";
const originalUser = UNAUTHENTICATED_USER;
mockGetUserId.mockRejectedValue(new Error("getUserId failed"));
const task = createTestTask({
_id: "task-1",
title: "Task One",
user: originalUser,
});
await repository.save(dateKey, task);
expect(mockAdapter.putTask).toHaveBeenCalledWith(
dateKey,
expect.objectContaining({
_id: "task-1",
user: originalUser,
}),
);
});

Copilot uses AI. Check for mistakes.
});

describe("delete", () => {
Expand Down
33 changes: 30 additions & 3 deletions packages/web/src/common/repositories/task/local.task.repository.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getUserId } from "@web/auth/auth.util";
import { UNAUTHENTICATED_USER } from "@web/common/constants/auth.constants";
import { getStorageAdapter } from "@web/common/storage/adapter/adapter";
import { Task } from "@web/common/types/task.types";
import { dispatchTasksSavedEvent } from "@web/common/utils/storage/storage.util";
Expand All @@ -21,14 +23,39 @@ export class LocalTaskRepository implements TaskRepository {

async save(dateKey: string, taskOrTasks: Task | Task[]): Promise<void> {
const tasks = Array.isArray(taskOrTasks) ? taskOrTasks : [taskOrTasks];
if (tasks.length === 1 && !Array.isArray(taskOrTasks)) {
await this.adapter.putTask(dateKey, tasks[0]);
const normalizedTasks = await this.normalizeUnauthenticatedUsers(tasks);

if (normalizedTasks.length === 1 && !Array.isArray(taskOrTasks)) {
await this.adapter.putTask(dateKey, normalizedTasks[0]);
} else {
await this.adapter.putTasks(dateKey, tasks);
await this.adapter.putTasks(dateKey, normalizedTasks);
}
dispatchTasksSavedEvent(dateKey);
}

private async normalizeUnauthenticatedUsers(tasks: Task[]): Promise<Task[]> {
let authenticatedUserId = UNAUTHENTICATED_USER;

try {
authenticatedUserId = await getUserId();
} catch (error) {
console.error("Failed to resolve user id while saving tasks:", error);
return tasks;
}

if (!authenticatedUserId || authenticatedUserId === UNAUTHENTICATED_USER) {
return tasks;
}

return tasks.map((task) => {
if (task.user !== UNAUTHENTICATED_USER) {
return task;
}

return { ...task, user: authenticatedUserId };
});
}

async delete(dateKey: string, taskId: string): Promise<void> {
const tasksForDate = await this.get(dateKey);
const isTaskInDate = tasksForDate.some((task) => task._id === taskId);
Expand Down
119 changes: 119 additions & 0 deletions packages/web/src/common/utils/sync/local-task-user-sync.util.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { createTestTask } from "@web/__tests__/utils/repositories/repository.test.factory";
import { prepareEmptyStorageForTests } from "@web/__tests__/utils/storage/indexeddb.test.util";
import { getUserId } from "@web/auth/auth.util";
import { UNAUTHENTICATED_USER } from "@web/common/constants/auth.constants";
import {
ensureStorageReady,
getStorageAdapter,
resetStorage,
} from "@web/common/storage/adapter/adapter";
import { syncLocalTaskUsersToAuthenticatedUser } from "./local-task-user-sync.util";

jest.mock("@web/auth/auth.util");

describe("syncLocalTaskUsersToAuthenticatedUser", () => {
const mockGetUserId = getUserId as jest.MockedFunction<typeof getUserId>;

beforeEach(async () => {
jest.clearAllMocks();
resetStorage();
await prepareEmptyStorageForTests();
await ensureStorageReady();
});

afterEach(() => {
resetStorage();
});

it("rewrites unauthenticated users across multiple dates", async () => {
mockGetUserId.mockResolvedValue("mongo-user-id");
const adapter = getStorageAdapter();

await adapter.putTasks("2024-01-01", [
createTestTask({ _id: "task-1", user: UNAUTHENTICATED_USER }),
createTestTask({ _id: "task-2", user: "already-authenticated-user" }),
]);
await adapter.putTasks("2024-01-02", [
createTestTask({ _id: "task-3", user: UNAUTHENTICATED_USER }),
]);

const syncedCount = await syncLocalTaskUsersToAuthenticatedUser();

expect(syncedCount).toBe(2);
await expect(adapter.getTasks("2024-01-01")).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ _id: "task-1", user: "mongo-user-id" }),
expect.objectContaining({
_id: "task-2",
user: "already-authenticated-user",
}),
]),
);
await expect(adapter.getTasks("2024-01-02")).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ _id: "task-3", user: "mongo-user-id" }),
]),
);
});

it("returns 0 when there are no tasks", async () => {
mockGetUserId.mockResolvedValue("mongo-user-id");

await expect(syncLocalTaskUsersToAuthenticatedUser()).resolves.toBe(0);
});

it("returns 0 and does not rewrite when user is unauthenticated", async () => {
mockGetUserId.mockResolvedValue(UNAUTHENTICATED_USER);
const adapter = getStorageAdapter();
await adapter.putTasks("2024-01-01", [
createTestTask({ _id: "task-1", user: UNAUTHENTICATED_USER }),
]);

const syncedCount = await syncLocalTaskUsersToAuthenticatedUser();

expect(syncedCount).toBe(0);
await expect(adapter.getTasks("2024-01-01")).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ _id: "task-1", user: UNAUTHENTICATED_USER }),
]),
);
});

it("returns 0 when all tasks already have authenticated users", async () => {
mockGetUserId.mockResolvedValue("mongo-user-id");
const adapter = getStorageAdapter();
await adapter.putTasks("2024-01-01", [
createTestTask({ _id: "task-1", user: "already-authenticated-user" }),
]);

await expect(syncLocalTaskUsersToAuthenticatedUser()).resolves.toBe(0);
await expect(adapter.getTasks("2024-01-01")).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
_id: "task-1",
user: "already-authenticated-user",
}),
]),
);
});

it("throws when user id lookup fails", async () => {
mockGetUserId.mockRejectedValue(new Error("Failed to resolve user id"));

await expect(syncLocalTaskUsersToAuthenticatedUser()).rejects.toThrow(
"Failed to resolve user id",
);
});

it("throws when loading tasks fails", async () => {
mockGetUserId.mockResolvedValue("mongo-user-id");
const adapter = getStorageAdapter();
jest
.spyOn(adapter, "getAllTasks")
.mockRejectedValueOnce(new Error("Failed to load tasks"));

await expect(syncLocalTaskUsersToAuthenticatedUser()).rejects.toThrow(
"Failed to load tasks",
);
});
});
Loading
Loading