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
79 changes: 63 additions & 16 deletions apps/backend/src/services/auth.service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,13 +16,48 @@ interface AuthTokens {
}

interface AuthResult {
user: {
id: string;
email: string;
};
user: User;
tokens: AuthTokens;
}

function mapUser(user: Prisma.UserGetPayload<object>): 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<User> {
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<AuthResult> {
const { data, error } = await supabaseAdmin.auth.signUp({
Expand All @@ -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) => {
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
120 changes: 120 additions & 0 deletions apps/backend/tests/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
9 changes: 5 additions & 4 deletions apps/frontend/src/lib/api/auth.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
95 changes: 95 additions & 0 deletions apps/frontend/src/lib/hooks/use-auth.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}

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!");
});
});
4 changes: 2 additions & 2 deletions apps/frontend/src/lib/hooks/use-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
},
Expand All @@ -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");
},
Expand Down
Loading