Skip to content

Commit cf07f3c

Browse files
feat(user-metadata): enhance Google metadata handling and update tests (#1531)
* feat(user-metadata): enhance Google metadata handling and update tests - Updated the user metadata service to assess Google connection and sync status, enriching metadata with connection status and sync state. - Introduced a new driver for user metadata service tests to streamline testing of metadata updates. - Modified existing tests to validate new Google metadata assessment logic, ensuring accurate handling of connection and sync states. - Refactored user driver to support conditional user creation based on Google connection status. - Cleaned up WebSocket server logic by removing deprecated Google token status checks on connection. * fix(backend): guard stale watch metadata assessment errors Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com> * fix(backend): prevent concurrent sync by skipping assessment in importIncremental Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com> * fix(sync,web): recover invalid sync tokens and handle connected status - fix(sync): force-restart sync when Google returns 410 error instead of delegating to assessGoogleMetadata, which checks token existence not validity - fix(web): add catch-all for connected users with unhandled sync status to show correct label Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com> * fix(web): remove redundant syncStatus check in useConnectGoogle hook Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com> * feat(sync): enhance gcal import handling in SyncController tests - Introduced new functions to wait for Google Calendar import start and end events. - Updated tests to utilize these functions for improved clarity and reliability. - Refactored import result parsing for better type safety and readability. - Adjusted test assertions to ensure correct behavior during import processes. * fix(util): improve error handling in waitUntilEvent utility - Added a catch block to handle errors from the afterEvent promise, ensuring proper rejection with a standardized Error object. - Enhanced error handling in the beforeEvent callback to ensure consistent error reporting. * feat(auth): enhance Google Calendar connection handling in useConnectGoogle hook - Refactored the useConnectGoogle hook to return a structured configuration for command actions and sidebar status. - Updated tests to reflect changes in the hook's return structure, ensuring accurate assertions for various connection states. - Improved the SidebarIconRow component to utilize the new configuration for displaying Google Calendar connection status. - Added a disabled state for the TooltipWrapper when the Google Calendar is connected or syncing. * feat(sync): enhance Google Calendar import functionality and testing - Added a `force` option to the `importGCal` method in `SyncControllerDriver` to allow forced imports. - Introduced a new `WatchDriver` for managing watch collection tests, improving test isolation and flexibility. - Updated `importGCal` method in `SyncController` to handle the `force` parameter, enabling forced sync operations. - Enhanced tests in `sync.controller.test.ts` to verify forced import behavior and ensure correct handling of completed sync states. - Improved user metadata service to return healthy status without active watches when not using HTTPS. - Updated user service tests to validate event sync token persistence without HTTPS, ensuring local sync health. * feat(sync): improve Google Calendar repair functionality in useConnectGoogle hook - Enhanced the useConnectGoogle hook to manage Google Calendar repair state, integrating a new repair action that triggers forced imports. - Updated the SyncApi to accept an options parameter for the importGCal method, allowing for forced sync operations. - Refactored tests for useConnectGoogle and related components to validate the new repair logic and ensure accurate assertions for various connection states. - Improved user interface feedback during repair processes, including updated tooltips and sidebar statuses. - Ensured that socket emissions for user metadata fetching are correctly triggered during import events. * docs(tests): update testing guidelines to avoid direct persistence layer imports - Added a note in testing documentation to discourage importing `mongoService` or other persistence layers directly in tests. - Recommended using test drivers (e.g., `UserDriver`, `WatchDriver`) to maintain test agnosticism towards the backing store, facilitating easier transitions away from MongoDB in the future. * fix(web): use state-specific labels for Google Calendar command palette Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com> * test(web): update test assertions to match state-specific Google Calendar labels Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com> * fix(web): prioritize reconnect_required over importing state in Google UI fix(backend): add race condition check before scheduling Google repair fix(web): only close command palette when repair triggered from command palette Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com> * fix(backend): remove unused driver methods after local refactor Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com> * fix(husky): run lint-staged in quiet mode during pre-commit hook * fix(sync): enhance Google Calendar sync handling and notification processing - Updated SyncController to skip recovery if a full import is already active for the user. - Refactored syncService to ignore stale notifications when no exact watch record exists, improving notification handling. - Enhanced tests to validate the new behavior for handling Google Calendar notifications and sync states. - Improved user metadata service to return appropriate sync statuses without scheduling unnecessary repairs. * fix(user-metadata): refactor user metadata retrieval and update logic - Introduced a new type for the response structure from SupertokensUserMetadata, improving type safety. - Updated the getStoredUserMetadata and updateUserMetadata methods to handle the new response format, ensuring proper error handling and returning the correct metadata. - Enhanced code readability by simplifying the response handling process. * feat(user-metadata): implement user metadata refresh and loading state management - Introduced a new utility function, `refreshUserMetadata`, to handle user metadata retrieval and state updates in the Redux store. - Enhanced the `userMetadataSlice` to include loading states, allowing for better user experience during metadata fetching. - Updated the `SessionProvider` to refresh user metadata upon session initialization and when a session already exists. - Refactored the `useConnectGoogle` hook to incorporate user metadata status, improving the handling of Google Calendar connection states. - Added tests for the new user metadata utility and updated existing tests to ensure proper functionality and state management. * test(DayCmdPalette): enhance tests for command palette button states - Mocked the `react-cmdk` library to provide a more accurate testing environment for the command palette component. - Updated test assertions to use `getByRole` for button state checks, ensuring better accessibility compliance and clarity in tests. - Improved the handling of disabled button states in tests for Google Calendar connection and syncing actions. * refactor(google-auth): update socket handling on Google revocation - Removed the use of `FETCH_USER_METADATA` in favor of directly reconnecting the socket to ensure a fresh session after Google revocation. - Updated the test case to reflect the new behavior, verifying that the socket reconnects instead of emitting a metadata fetch event. - Improved code clarity by simplifying the revocation handling logic. * feat(websocket): enhance Google Calendar import handling with structured payloads - Introduced a new type, `ImportGCalEndPayload`, to standardize the payload structure for Google Calendar import completion events. - Updated the `handleImportGCalEnd` method to accept the new payload type, improving type safety and clarity. - Refactored various components and tests to utilize the structured payload, ensuring consistent handling of import results and error messages. - Enhanced tests to validate the new payload structure and its impact on the import flow. * feat(google-auth): enhance Google sign-in handling with new utility functions - Introduced utility functions for managing Google sign-in success and session user ID resolution, improving the handling of Google authentication flows. - Updated the `signInUpPOST` API implementation to integrate Google sign-in success handling, ensuring proper session management. - Added tests for the new utility functions to validate their behavior in various scenarios, enhancing overall test coverage. - Refactored the `useGoogleAuth` hook to support reconnect intents, improving user experience during Google Calendar reconnections. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com>
1 parent 7129133 commit cf07f3c

59 files changed

Lines changed: 2925 additions & 690 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.cursorrules/testing.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ const button = container.querySelector('.add-button');
7373
- Use async/await for asynchronous tests
7474
- Mock external services (Google Calendar API, MongoDB) appropriately
7575
- Test error handling and edge cases
76+
- **Do not import `mongoService` or other persistence layers directly in tests.** Use the test drivers in `packages/backend/src/__tests__/drivers/` (e.g. `UserDriver`, `WatchDriver`) so that tests stay agnostic of the backing store and switching away from Mongo later does not require test changes.
7677

7778
**Real examples:**
7879

.husky/pre-commit

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
#!/bin/sh
22
. "$(dirname "$0")/_/husky.sh"
33

4-
yarn lint-staged
4+
yarn lint-staged --quiet

docs/testing-playbook.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,12 @@ Preferred style:
209209
- realistic request flows when possible
210210
- mock only external services, not internal business logic
211211

212+
**Do not import `mongoService` (or other persistence implementations) directly in tests.** Use test drivers instead (e.g. `UserDriver`, `WatchDriver` in `packages/backend/src/__tests__/drivers/`). Drivers encapsulate persistence so that switching away from Mongo (or another store) in the future does not require changing test code.
213+
212214
Useful anchors:
213215

214216
- `packages/backend/src/__tests__`
217+
- `packages/backend/src/__tests__/drivers/`
215218
- `packages/backend/src/event/services/*.test.ts`
216219
- `packages/backend/src/sync/**/*.test.ts`
217220

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"postinstall": "husky install | chmod ug+x .husky/*",
3030
"test": "cross-env TZ=Etc/UTC ./node_modules/.bin/jest",
3131
"test:e2e": "playwright test",
32-
"test:backend": "yarn test backend",
32+
"test:backend": "cross-env TZ=Etc/UTC ./node_modules/.bin/jest --selectProjects backend",
3333
"test:core": "yarn test core",
3434
"test:web": "cross-env TZ=Etc/UTC ./node_modules/.bin/jest web",
3535
"test:scripts": "yarn test scripts",

packages/backend/src/__tests__/drivers/sync.controller.driver.ts

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import request from "supertest";
22
import { GCAL_NOTIFICATION_ENDPOINT } from "@core/constants/core.constants";
3-
import {
4-
IMPORT_GCAL_END,
5-
IMPORT_GCAL_START,
6-
} from "@core/constants/websocket.constants";
73
import { Status } from "@core/errors/status.codes";
84
import { type Payload_Sync_Notif } from "@core/types/sync.types";
95
import { type BaseDriver } from "@backend/__tests__/drivers/base.driver";
@@ -12,33 +8,6 @@ import { encodeChannelToken } from "@backend/sync/util/watch.util";
128
export class SyncControllerDriver {
139
constructor(private readonly baseDriver: BaseDriver) {}
1410

15-
async waitUntilImportGCalStart<Result = unknown[]>(
16-
websocketClient: ReturnType<BaseDriver["createWebsocketClient"]>,
17-
beforeEvent: () => Promise<unknown> = () => Promise.resolve(),
18-
afterEvent: (...args: void[]) => Promise<Result> = (...args) =>
19-
Promise.resolve(args as Result),
20-
): Promise<Result> {
21-
return this.baseDriver.waitUntilWebsocketEvent<void[], Result>(
22-
websocketClient,
23-
IMPORT_GCAL_START,
24-
beforeEvent,
25-
afterEvent,
26-
);
27-
}
28-
29-
async waitUntilImportGCalEnd<Result = unknown[]>(
30-
websocketClient: ReturnType<BaseDriver["createWebsocketClient"]>,
31-
beforeEvent: () => Promise<unknown> = () => Promise.resolve(),
32-
afterEvent: (...args: [string | undefined]) => Promise<Result> = (
33-
...args
34-
) => Promise.resolve(args as Result),
35-
): Promise<Result> {
36-
return this.baseDriver.waitUntilWebsocketEvent<
37-
[string | undefined],
38-
Result
39-
>(websocketClient, IMPORT_GCAL_END, beforeEvent, afterEvent);
40-
}
41-
4211
async handleGoogleNotification(
4312
{
4413
token,
@@ -64,12 +33,14 @@ export class SyncControllerDriver {
6433

6534
async importGCal(
6635
session?: { userId: string },
36+
body?: { force?: boolean },
6737
status: Status = Status.NO_CONTENT,
6838
): Promise<
6939
Omit<request.Response, "body"> & { body: { id: string; status: string } }
7040
> {
7141
return request(this.baseDriver.getServerUri())
7242
.post("/api/sync/import-gcal")
43+
.send(body)
7344
.use(this.baseDriver.setSessionPlugin(session))
7445
.expect(status);
7546
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { JSONObject } from "supertokens-node/recipe/usermetadata";
2+
import { type UserMetadata } from "@core/types/user.types";
3+
import userMetadataService from "@backend/user/services/user-metadata.service";
4+
5+
export class UserMetadataServiceDriver {
6+
updateUserMetadata(params: {
7+
userId: string;
8+
data: Partial<UserMetadata>;
9+
}): Promise<UserMetadata> {
10+
return userMetadataService.updateUserMetadata(params);
11+
}
12+
13+
fetchUserMetadata(
14+
userId: string,
15+
userContext?: Record<string, JSONObject>,
16+
): Promise<UserMetadata> {
17+
return userMetadataService.fetchUserMetadata(userId, userContext);
18+
}
19+
}

packages/backend/src/__tests__/drivers/user.driver.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import userService from "../../user/services/user.service";
77

88
interface CreateUserOptions {
99
withGoogleRefreshToken?: boolean;
10+
/** When false, creates a user with no Google data (never connected). */
11+
withGoogle?: boolean;
1012
}
1113

1214
export class UserDriver {
@@ -38,7 +40,7 @@ export class UserDriver {
3840
static async createUser(
3941
options: CreateUserOptions = {},
4042
): Promise<WithId<Schema_User>> {
41-
const { withGoogleRefreshToken = true } = options;
43+
const { withGoogleRefreshToken = true, withGoogle = true } = options;
4244
const gUser = UserDriver.generateGoogleUser();
4345
const gRefreshToken = faker.internet.jwt();
4446

@@ -49,6 +51,14 @@ export class UserDriver {
4951

5052
const _id = new ObjectId(userId);
5153

54+
// Simulate "user never connected Google" by removing all Google data
55+
if (!withGoogle) {
56+
await mongoService.user.updateOne({ _id }, { $unset: { google: "" } });
57+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- intentionally omit google from returned user
58+
const { google: _google, ...rest } = user;
59+
return { ...rest, _id };
60+
}
61+
5262
// Remove refresh token if requested (simulates revoked token scenario)
5363
if (!withGoogleRefreshToken) {
5464
await mongoService.user.updateOne(
@@ -67,6 +77,8 @@ export class UserDriver {
6777
}
6878

6979
static async createUsers(count: number): Promise<Array<WithId<Schema_User>>> {
70-
return Promise.all(Array.from({ length: count }, UserDriver.createUser));
80+
return Promise.all(
81+
Array.from({ length: count }, () => UserDriver.createUser()),
82+
);
7183
}
7284
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import mongoService from "@backend/common/services/mongo.service";
2+
3+
/**
4+
* Test driver for the watch collection.
5+
* Use this instead of importing mongoService in tests so persistence can be
6+
* swapped (e.g. away from Mongo) without changing test code.
7+
*/
8+
export class WatchDriver {
9+
static deleteManyByUser(
10+
userId: string,
11+
): ReturnType<typeof mongoService.watch.deleteMany> {
12+
return mongoService.watch.deleteMany({ user: userId });
13+
}
14+
}

packages/backend/src/common/middleware/supertokens.middleware.ts

Lines changed: 36 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import cors from "cors";
2-
import { type Credentials, type TokenPayload } from "google-auth-library";
32
import { ObjectId } from "mongodb";
43
import supertokens, { default as SuperTokens, User } from "supertokens-node";
54
import Dashboard from "supertokens-node/recipe/dashboard";
@@ -15,9 +14,14 @@ import { BaseError } from "@core/errors/errors.base";
1514
import { Status } from "@core/errors/status.codes";
1615
import { zObjectId } from "@core/types/type.utils";
1716
import compassAuthService from "@backend/auth/services/compass.auth.service";
18-
import type { GoogleSignInSuccess } from "@backend/auth/services/google/google.auth.success.service";
1917
import { handleGoogleAuth } from "@backend/auth/services/google/google.auth.success.service";
2018
import { ENV } from "@backend/common/constants/env.constants";
19+
import {
20+
type CreateGoogleSignInResponse,
21+
type ThirdPartySignInUpInput,
22+
createGoogleSignInSuccess,
23+
getGoogleAuthIntent,
24+
} from "@backend/common/middleware/supertokens.middleware.util";
2125
import mongoService from "@backend/common/services/mongo.service";
2226
import syncService from "@backend/sync/services/sync.service";
2327
import userMetadataService from "@backend/user/services/user-metadata.service";
@@ -108,11 +112,36 @@ export const initSupertokens = () => {
108112
}),
109113
};
110114
},
111-
async signInUp(
112-
input: Parameters<typeof originalImplementation.signInUp>[0],
113-
) {
114-
const response = await originalImplementation.signInUp(input);
115-
await runGoogleAuth(response, input);
115+
};
116+
},
117+
apis(originalImplementation) {
118+
return {
119+
...originalImplementation,
120+
async signInUpPOST(input: ThirdPartySignInUpInput) {
121+
if (!originalImplementation.signInUpPOST) {
122+
throw new BaseError(
123+
"signInUpPOST not implemented",
124+
"signInUpPOST not implemented",
125+
Status.BAD_REQUEST,
126+
true,
127+
);
128+
}
129+
130+
const response =
131+
await originalImplementation.signInUpPOST(input);
132+
const body = (await input.options.req.getJSONBody()) as {
133+
googleAuthIntent?: unknown;
134+
};
135+
const success = createGoogleSignInSuccess(
136+
response as CreateGoogleSignInResponse,
137+
getGoogleAuthIntent(body?.googleAuthIntent),
138+
input.session?.getUserId() ?? null,
139+
);
140+
141+
if (success) {
142+
await handleGoogleAuth(success, compassAuthService);
143+
}
144+
116145
return response;
117146
},
118147
};
@@ -174,28 +203,3 @@ export const supertokensCors = () =>
174203
],
175204
credentials: true,
176205
});
177-
178-
function runGoogleAuth(
179-
response: { status: string } & Record<string, unknown>,
180-
input: { session?: { getUserId(): string } },
181-
): Promise<void> {
182-
if (response.status !== "OK") return Promise.resolve();
183-
184-
const ok = response as unknown as {
185-
rawUserInfoFromProvider: { fromIdTokenPayload: TokenPayload };
186-
oAuthTokens: Pick<Credentials, "refresh_token" | "access_token">;
187-
createdNewRecipeUser: boolean;
188-
user: { id: string; loginMethods: unknown[] };
189-
};
190-
191-
const success: GoogleSignInSuccess = {
192-
providerUser: ok.rawUserInfoFromProvider.fromIdTokenPayload,
193-
oAuthTokens: ok.oAuthTokens,
194-
createdNewRecipeUser: ok.createdNewRecipeUser,
195-
recipeUserId: ok.user.id,
196-
loginMethodsLength: ok.user.loginMethods.length,
197-
sessionUserId: input.session ? input.session.getUserId() : null,
198-
};
199-
200-
return handleGoogleAuth(success, compassAuthService);
201-
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { type TokenPayload } from "google-auth-library";
2+
import { faker } from "@faker-js/faker";
3+
import {
4+
createGoogleSignInSuccess,
5+
resolveGoogleSessionUserId,
6+
} from "@backend/common/middleware/supertokens.middleware.util";
7+
8+
describe("supertokens.middleware.util", () => {
9+
describe("resolveGoogleSessionUserId", () => {
10+
it("prefers the current session when one exists", () => {
11+
const sessionUserId = faker.database.mongodbObjectId();
12+
const recipeUserId = faker.database.mongodbObjectId();
13+
14+
expect(
15+
resolveGoogleSessionUserId({
16+
sessionUserId,
17+
googleAuthIntent: "reconnect",
18+
createdNewRecipeUser: false,
19+
recipeUserId,
20+
}),
21+
).toBe(sessionUserId);
22+
});
23+
24+
it("uses the recipe user id for reconnects without a session", () => {
25+
const recipeUserId = faker.database.mongodbObjectId();
26+
27+
expect(
28+
resolveGoogleSessionUserId({
29+
sessionUserId: null,
30+
googleAuthIntent: "reconnect",
31+
createdNewRecipeUser: false,
32+
recipeUserId,
33+
}),
34+
).toBe(recipeUserId);
35+
});
36+
37+
it("keeps normal returning users on the sign-in path without reconnect intent", () => {
38+
expect(
39+
resolveGoogleSessionUserId({
40+
sessionUserId: null,
41+
createdNewRecipeUser: false,
42+
recipeUserId: faker.database.mongodbObjectId(),
43+
}),
44+
).toBeNull();
45+
});
46+
47+
it("does not force reconnect behavior for new users", () => {
48+
expect(
49+
resolveGoogleSessionUserId({
50+
sessionUserId: null,
51+
googleAuthIntent: "reconnect",
52+
createdNewRecipeUser: true,
53+
recipeUserId: faker.database.mongodbObjectId(),
54+
}),
55+
).toBeNull();
56+
});
57+
});
58+
59+
describe("createGoogleSignInSuccess", () => {
60+
it("returns null for non-OK responses", () => {
61+
expect(
62+
createGoogleSignInSuccess({
63+
status: "SIGN_IN_UP_NOT_ALLOWED",
64+
} as Parameters<typeof createGoogleSignInSuccess>[0]),
65+
).toBeNull();
66+
});
67+
68+
it("embeds reconnect fallback user id into the auth success payload", () => {
69+
const recipeUserId = faker.database.mongodbObjectId();
70+
const success = createGoogleSignInSuccess(
71+
{
72+
status: "OK",
73+
rawUserInfoFromProvider: {
74+
fromIdTokenPayload: {
75+
sub: faker.string.uuid(),
76+
email: faker.internet.email(),
77+
} as TokenPayload,
78+
},
79+
oAuthTokens: {
80+
refresh_token: faker.string.uuid(),
81+
access_token: faker.internet.jwt(),
82+
},
83+
createdNewRecipeUser: false,
84+
user: {
85+
id: recipeUserId,
86+
loginMethods: [{}],
87+
},
88+
} as Parameters<typeof createGoogleSignInSuccess>[0],
89+
"reconnect",
90+
null,
91+
);
92+
93+
expect(success).toMatchObject({
94+
createdNewRecipeUser: false,
95+
recipeUserId,
96+
sessionUserId: recipeUserId,
97+
loginMethodsLength: 1,
98+
});
99+
});
100+
});
101+
});

0 commit comments

Comments
 (0)