diff --git a/apps/backend/src/services/auth.service.ts b/apps/backend/src/services/auth.service.ts index d4970c6..69c4476 100644 --- a/apps/backend/src/services/auth.service.ts +++ b/apps/backend/src/services/auth.service.ts @@ -1,3 +1,6 @@ +import type { Prisma } from "@prisma/client"; +import type { User } from "@snacktrack/shared-types"; +import { prisma } from "../config/database.js"; import { supabaseAdmin } from "../config/supabase.js"; import { AppError } from "../utils/AppError.js"; import { logger } from "../utils/logger.js"; @@ -13,13 +16,48 @@ interface AuthTokens { } interface AuthResult { - user: { - id: string; - email: string; - }; + user: User; tokens: AuthTokens; } +function mapUser(user: Prisma.UserGetPayload): User { + return { + id: user.id, + email: user.email, + displayName: user.displayName, + dateOfBirth: user.dateOfBirth?.toISOString().split("T")[0] ?? null, + gender: user.gender as User["gender"], + heightCm: user.heightCm, + weightKg: user.weightKg, + activityLevel: user.activityLevel as User["activityLevel"], + healthGoal: user.healthGoal as User["healthGoal"], + unitPreference: user.unitPreference as User["unitPreference"], + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + }; +} + +async function ensureAppUser( + userId: string, + email: string, + displayName?: string | null, +): Promise { + const user = await prisma.user.upsert({ + where: { id: userId }, + create: { + id: userId, + email, + displayName: displayName ?? null, + }, + update: { + email, + ...(displayName !== undefined ? { displayName } : {}), + }, + }); + + return mapUser(user); +} + export class AuthService { async signUp(input: SignupInput): Promise { const { data, error } = await supabaseAdmin.auth.signUp({ @@ -42,6 +80,22 @@ export class AuthService { throw new AppError(400, "SIGNUP_FAILED", "Failed to create account"); } const userId = data.user.id; + let user: User; + + try { + user = await ensureAppUser(userId, data.user.email!, input.displayName); + } catch (error) { + try { + await supabaseAdmin.auth.admin.deleteUser(userId); + } catch (cleanupError) { + logger.error( + { cleanupError, userId }, + "Failed to clean up Supabase user after signup rollback", + ); + } + logger.error({ error, userId }, "Failed to create application user during signup"); + throw new AppError(500, "SIGNUP_FAILED", "Failed to create account"); + } // Warm up a per-user model in the background; auth should not fail if ML training is down. void mlService.trainUserModel(userId).catch((error) => { @@ -50,10 +104,7 @@ export class AuthService { }); return { - user: { - id: userId, - email: data.user.email!, - }, + user, tokens: { accessToken: data.session.access_token, refreshToken: data.session.refresh_token, @@ -77,12 +128,10 @@ export class AuthService { if (!data.user || !data.session) { throw new AppError(401, "INVALID_CREDENTIALS", "Invalid email or password"); } + const user = await ensureAppUser(data.user.id, data.user.email!); return { - user: { - id: data.user.id, - email: data.user.email!, - }, + user, tokens: { accessToken: data.session.access_token, refreshToken: data.session.refresh_token, @@ -113,12 +162,10 @@ export class AuthService { if (error || !data.user || !data.session) { throw new AppError(401, "REFRESH_FAILED", "Invalid or expired refresh token"); } + const user = await ensureAppUser(data.user.id, data.user.email!); return { - user: { - id: data.user.id, - email: data.user.email!, - }, + user, tokens: { accessToken: data.session.access_token, refreshToken: data.session.refresh_token, diff --git a/apps/backend/tests/auth.test.ts b/apps/backend/tests/auth.test.ts new file mode 100644 index 0000000..656c54c --- /dev/null +++ b/apps/backend/tests/auth.test.ts @@ -0,0 +1,120 @@ +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: { + deleteUser: vi.fn(), + 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-446655440000"; +const TEST_EMAIL = "new-user@snacktrack.dev"; + +const appUser = { + id: TEST_USER_ID, + email: TEST_EMAIL, + displayName: "New User", + dateOfBirth: null, + gender: null, + heightCm: null, + weightKg: null, + activityLevel: null, + healthGoal: null, + unitPreference: "metric", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), +}; + +describe("Auth endpoints", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates the application user row during signup", async () => { + vi.mocked(supabaseAdmin.auth.signUp).mockResolvedValue({ + data: { + user: { + id: TEST_USER_ID, + email: TEST_EMAIL, + }, + session: { + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 3600, + expires_at: 1770000000, + }, + }, + error: null, + } as never); + vi.mocked(prisma.user.upsert).mockResolvedValue(appUser 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(res.body.data.user.id).toBe(TEST_USER_ID); + expect(res.body.data.user.unitPreference).toBe("metric"); + expect(res.body.data.tokens.accessToken).toBe("access-token"); + expect(res.body.data.tokens.refreshToken).toBe("refresh-token"); + expect(mlService.trainUserModel).toHaveBeenCalledWith(TEST_USER_ID); + }); +}); diff --git a/apps/frontend/src/lib/api/auth.api.ts b/apps/frontend/src/lib/api/auth.api.ts index a3a666b..57e03cf 100644 --- a/apps/frontend/src/lib/api/auth.api.ts +++ b/apps/frontend/src/lib/api/auth.api.ts @@ -16,10 +16,11 @@ interface AuthResponse { status: string; data: { user: User; - session: { - access_token: string; - refresh_token: string; - expires_at: number; + tokens: { + accessToken: string; + refreshToken: string; + expiresIn: number; + expiresAt: number; }; }; error: null; diff --git a/apps/frontend/src/lib/hooks/use-auth.test.tsx b/apps/frontend/src/lib/hooks/use-auth.test.tsx new file mode 100644 index 0000000..7ac5cfe --- /dev/null +++ b/apps/frontend/src/lib/hooks/use-auth.test.tsx @@ -0,0 +1,95 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import React from "react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { authApi } from "../api/auth.api"; +import { useAuthStore } from "../store/auth-store"; +import { useAuth } from "./use-auth"; + +const { pushMock, toastSuccessMock } = vi.hoisted(() => ({ + pushMock: vi.fn(), + toastSuccessMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: pushMock, + }), +})); + +vi.mock("sonner", () => ({ + toast: { + success: toastSuccessMock, + }, +})); + +vi.mock("../api/auth.api", () => ({ + authApi: { + login: vi.fn(), + signup: vi.fn(), + logout: vi.fn(), + refreshToken: vi.fn(), + getCurrentUser: vi.fn(), + }, +})); + +const mockUser = { + id: "user-1", + email: "user@example.com", + displayName: "Test User", + unitPreference: "metric", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useAuth", () => { + beforeEach(() => { + localStorage.clear(); + pushMock.mockClear(); + toastSuccessMock.mockClear(); + vi.clearAllMocks(); + useAuthStore.setState({ + user: null, + accessToken: null, + refreshToken: null, + isHydrated: false, + }); + }); + + it("stores backend token response after login", async () => { + vi.mocked(authApi.login).mockResolvedValue({ + user: mockUser, + tokens: { + accessToken: "access-token", + refreshToken: "refresh-token", + expiresIn: 3600, + expiresAt: 1770000000, + }, + }); + + const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() }); + + act(() => { + result.current.login({ email: mockUser.email, password: "Password1" }); + }); + + await waitFor(() => { + expect(localStorage.getItem("accessToken")).toBe("access-token"); + }); + expect(localStorage.getItem("refreshToken")).toBe("refresh-token"); + expect(useAuthStore.getState().user?.id).toBe("user-1"); + expect(pushMock).toHaveBeenCalledWith("/dashboard"); + expect(toastSuccessMock).toHaveBeenCalledWith("Welcome back!"); + }); +}); diff --git a/apps/frontend/src/lib/hooks/use-auth.ts b/apps/frontend/src/lib/hooks/use-auth.ts index 9eb8826..ed07111 100644 --- a/apps/frontend/src/lib/hooks/use-auth.ts +++ b/apps/frontend/src/lib/hooks/use-auth.ts @@ -12,7 +12,7 @@ export function useAuth() { const loginMutation = useMutation({ mutationFn: authApi.login, onSuccess: (data) => { - setAuth(data.user, data.session.access_token, data.session.refresh_token); + setAuth(data.user, data.tokens.accessToken, data.tokens.refreshToken); toast.success("Welcome back!"); router.push("/dashboard"); }, @@ -21,7 +21,7 @@ export function useAuth() { const signupMutation = useMutation({ mutationFn: authApi.signup, onSuccess: (data) => { - setAuth(data.user, data.session.access_token, data.session.refresh_token); + setAuth(data.user, data.tokens.accessToken, data.tokens.refreshToken); toast.success("Account created successfully!"); router.push("/dashboard"); },