From d1c3921422b085e3386978898fc3c54baa1de349 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 16 May 2026 11:10:09 +0000 Subject: [PATCH] Fix auth user profile sync Co-authored-by: Vasanth Panuganti --- apps/backend/src/services/auth.service.ts | 41 +++++++ apps/backend/tests/auth.test.ts | 129 ++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 apps/backend/tests/auth.test.ts diff --git a/apps/backend/src/services/auth.service.ts b/apps/backend/src/services/auth.service.ts index d4970c6..df32e39 100644 --- a/apps/backend/src/services/auth.service.ts +++ b/apps/backend/src/services/auth.service.ts @@ -1,4 +1,5 @@ import { supabaseAdmin } from "../config/supabase.js"; +import { prisma } from "../config/database.js"; import { AppError } from "../utils/AppError.js"; import { logger } from "../utils/logger.js"; import type { SignupInput, LoginInput, RefreshInput } from "../schemas/auth.schema.js"; @@ -20,7 +21,41 @@ interface AuthResult { tokens: AuthTokens; } +type SupabaseAuthUser = { + id: string; + email?: string | null; + user_metadata?: Record | null; +}; + export class AuthService { + private async syncUserRecord( + authUser: SupabaseAuthUser, + displayName?: string, + ): Promise { + if (!authUser.email) { + throw new AppError(400, "AUTH_USER_MISSING_EMAIL", "Authenticated user is missing an email"); + } + + const metadataDisplayName = + typeof authUser.user_metadata?.display_name === "string" + ? authUser.user_metadata.display_name + : undefined; + const resolvedDisplayName = displayName ?? metadataDisplayName; + + await prisma.user.upsert({ + where: { id: authUser.id }, + create: { + id: authUser.id, + email: authUser.email, + displayName: resolvedDisplayName, + }, + update: { + email: authUser.email, + ...(displayName ? { displayName } : {}), + }, + }); + } + async signUp(input: SignupInput): Promise { const { data, error } = await supabaseAdmin.auth.signUp({ email: input.email, @@ -43,6 +78,8 @@ export class AuthService { } const userId = data.user.id; + await this.syncUserRecord(data.user, input.displayName); + // Warm up a per-user model in the background; auth should not fail if ML training is down. void mlService.trainUserModel(userId).catch((error) => { captureMlFailure(error, { operation: "train-after-signup", userId }); @@ -78,6 +115,8 @@ export class AuthService { throw new AppError(401, "INVALID_CREDENTIALS", "Invalid email or password"); } + await this.syncUserRecord(data.user); + return { user: { id: data.user.id, @@ -114,6 +153,8 @@ export class AuthService { throw new AppError(401, "REFRESH_FAILED", "Invalid or expired refresh token"); } + await this.syncUserRecord(data.user); + return { user: { id: data.user.id, diff --git a/apps/backend/tests/auth.test.ts b/apps/backend/tests/auth.test.ts new file mode 100644 index 0000000..2097847 --- /dev/null +++ b/apps/backend/tests/auth.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +vi.mock("../src/config/database.js", () => ({ + isDatabaseHealthy: vi.fn().mockResolvedValue(true), + prisma: { + user: { + upsert: vi.fn(), + }, + }, +})); + +vi.mock("../src/config/redis.js", () => ({ + isRedisHealthy: vi.fn().mockResolvedValue(true), + redis: { on: vi.fn() }, +})); + +vi.mock("../src/config/supabase.js", () => ({ + supabaseAdmin: { + auth: { + signUp: vi.fn(), + signInWithPassword: vi.fn(), + refreshSession: vi.fn(), + getUser: vi.fn(), + admin: { signOut: vi.fn() }, + }, + }, + createUserClient: vi.fn(), +})); + +vi.mock("../src/services/ml.service.js", () => ({ + mlService: { + trainUserModel: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock("../src/config/sentry.js", () => ({ + captureMlFailure: vi.fn(), +})); + +vi.mock("../src/utils/logger.js", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), error: vi.fn(), warn: vi.fn() }, +})); + +import { createApp } from "../src/app.js"; +import { prisma } from "../src/config/database.js"; +import { supabaseAdmin } from "../src/config/supabase.js"; +import { mlService } from "../src/services/ml.service.js"; + +const app = createApp(); + +const TEST_USER_ID = "550e8400-e29b-41d4-a716-446655440123"; +const TEST_EMAIL = "new-user@snacktrack.dev"; +const session = { + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 3600, + expires_at: 1234567890, +}; + +describe("Auth endpoints", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.user.upsert).mockResolvedValue({} as never); + }); + + it("creates the application user row after Supabase signup succeeds", async () => { + vi.mocked(supabaseAdmin.auth.signUp).mockResolvedValue({ + data: { + user: { id: TEST_USER_ID, email: TEST_EMAIL }, + session, + }, + error: null, + } as never); + + const res = await request(app).post("/api/v1/auth/signup").send({ + email: TEST_EMAIL, + password: "Password1", + displayName: "New User", + }); + + expect(res.status).toBe(201); + expect(prisma.user.upsert).toHaveBeenCalledWith({ + where: { id: TEST_USER_ID }, + create: { + id: TEST_USER_ID, + email: TEST_EMAIL, + displayName: "New User", + }, + update: { + email: TEST_EMAIL, + displayName: "New User", + }, + }); + expect(mlService.trainUserModel).toHaveBeenCalledWith(TEST_USER_ID); + }); + + it("repairs a missing application user row after login succeeds", async () => { + vi.mocked(supabaseAdmin.auth.signInWithPassword).mockResolvedValue({ + data: { + user: { + id: TEST_USER_ID, + email: TEST_EMAIL, + user_metadata: { display_name: "Existing User" }, + }, + session, + }, + error: null, + } as never); + + const res = await request(app).post("/api/v1/auth/login").send({ + email: TEST_EMAIL, + password: "Password1", + }); + + expect(res.status).toBe(200); + expect(prisma.user.upsert).toHaveBeenCalledWith({ + where: { id: TEST_USER_ID }, + create: { + id: TEST_USER_ID, + email: TEST_EMAIL, + displayName: "Existing User", + }, + update: { + email: TEST_EMAIL, + }, + }); + }); +});