fix(auth): restore JWT and align CRUD with backend routes

This commit is contained in:
Arthur Bassi 2026-06-15 11:17:40 -03:00
parent 7be969951c
commit 4a67d6b16f
17 changed files with 284 additions and 219 deletions

View file

@ -1,30 +1,15 @@
import { expect, test, type Page } from "@playwright/test";
const TEST_USER = {
id: 1,
token: "test-jwt-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "user@seahavenind.com",
name: "Test User",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Test User",
id: "1",
};
async function mockSession(page: Page, isAuthenticated: () => boolean = () => false) {
await page.route("**/api/Authentication/GetCurrentUser", async (route) => {
if (isAuthenticated()) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: TEST_USER }),
});
return;
}
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ message: "Unauthorized" }),
});
});
}
async function mockDashboardStats(page: Page) {
await page.route("**/api/Dashboard/Stats", async (route) => {
await route.fulfill({
@ -44,7 +29,9 @@ async function mockDashboardStats(page: Page) {
test.describe("Login page", () => {
test.beforeEach(async ({ page }) => {
await mockSession(page);
await page.addInitScript(() => {
localStorage.removeItem("auth");
});
await mockDashboardStats(page);
});
@ -86,13 +73,7 @@ test.describe("Login page", () => {
});
test("logs in successfully and navigates to dashboard", async ({ page }) => {
let authenticated = false;
await page.unroute("**/api/Authentication/GetCurrentUser");
await mockSession(page, () => authenticated);
await page.route("**/api/Authentication/Login", async (route) => {
authenticated = true;
await route.fulfill({
status: 200,
contentType: "application/json",
@ -109,5 +90,9 @@ test.describe("Login page", () => {
await expect(page).toHaveURL(/\/$/);
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Welcome back" })).not.toBeVisible();
const storedAuth = await page.evaluate(() => localStorage.getItem("auth"));
expect(storedAuth).toContain(TEST_USER.token);
expect(storedAuth).toContain(TEST_USER.fullname);
});
});

View file

@ -1,9 +1,6 @@
export const API_PATHS = {
authentication: {
login: "Authentication/Login",
logout: "Authentication/Logout",
refresh: "Authentication/Refresh",
getCurrentUser: "Authentication/GetCurrentUser",
changePassword: "Authentication/ChangePassword",
forgetPassword: "Authentication/ForgetPassword",
verificationCode: "Authentication/VerificationCode",
@ -15,6 +12,9 @@ export const API_PATHS = {
create: "WorkOrder/Create",
update: "WorkOrder/Update",
delete: "WorkOrder/DeleteWorkOrder",
addWorkorder: "WorkOrder/AddWorkorder",
editWorkorder: "WorkOrder/EditWorkorder",
deleteWorkorder: "WorkOrder/DeleteWorkorder",
addCommentJson: "WorkOrder/AddCommentJson",
changeAssignment: "WorkOrder/ChangeAssignment",
updateChecklistItem: "WorkOrder/UpdateChecklistItem",
@ -54,6 +54,9 @@ export const API_PATHS = {
createEmployee: "Employee/CreateEmployee",
updateEmployee: "Employee/UpdateEmployee",
deleteEmployee: "Employee/DeleteEmployee",
addEmployees: "Employee/AddEmployees",
editEmployees: "Employee/EditEmployees",
deleteEmployees: "Employee/DeleteEmployees",
dropdown: "Employee/Dropdown",
},
contact: {
@ -64,6 +67,7 @@ export const API_PATHS = {
delete: "Contact/Delete",
addContacts: "Contact/AddContacts",
editContacts: "Contact/EditContacts",
deleteContacts: "Contact/DeleteContacts",
dropdown: "Contact/Dropdown",
getLocations: "Contact/GetLocations",
},
@ -85,6 +89,9 @@ export const API_PATHS = {
addAccount: "Account/AddAccount",
editAccount: "Account/EditAccount",
deleteAccount: "Account/DeleteAccount",
addAccounts: "Account/AddAccounts",
editAccounts: "Account/EditAccounts",
deleteAccounts: "Account/DeleteAccounts",
dropdown: "Account/Dropdown",
},
asset: {

View file

@ -4,9 +4,7 @@ import { API_PATHS } from "@/api/api-paths";
import { createApiErrorFromResponse, mapHttpStatusToMessage } from "@/api/api-error";
import { queryKeys } from "@/infra/query-key/query-key";
import queryClient from "@/lib/query/query-client";
let isRefreshing = false;
let refreshPromise: Promise<boolean> | null = null;
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
function resolvePrefixUrl(url: string): string {
const trimmed = url.trim() || "/api";
@ -26,6 +24,7 @@ function resolvePrefixUrl(url: string): string {
}
function clearSessionAndRedirectToLogin(): void {
clearAuth();
queryClient.setQueryData(queryKeys.auth.session(), null);
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
@ -33,49 +32,25 @@ function clearSessionAndRedirectToLogin(): void {
}
}
async function refreshSession(): Promise<boolean> {
if (isRefreshing && refreshPromise) {
return refreshPromise;
}
isRefreshing = true;
refreshPromise = (async () => {
try {
const response = await api.post(API_PATHS.authentication.refresh, {
retry: { limit: 0 },
});
return response.ok;
} catch {
return false;
} finally {
isRefreshing = false;
refreshPromise = null;
}
})();
return refreshPromise;
}
function isRefreshRequest(request: Request): boolean {
return request.url.includes(API_PATHS.authentication.refresh);
}
function isAuthSessionRequest(request: Request): boolean {
return (
request.url.includes(API_PATHS.authentication.getCurrentUser) ||
request.url.includes(API_PATHS.authentication.login) ||
request.url.includes(API_PATHS.authentication.logout)
);
function isAuthLoginRequest(request: Request): boolean {
return request.url.includes(API_PATHS.authentication.login);
}
export const api: KyInstance = ky.create({
prefixUrl: resolvePrefixUrl(env.apiUrl),
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
hooks: {
beforeRequest: [
(request) => {
const token = getAccessToken();
if (token) {
request.headers.set("Authorization", token);
}
},
],
beforeError: [
async (error: HTTPError) => {
const { response } = error;
@ -92,22 +67,12 @@ export const api: KyInstance = ky.create({
],
afterResponse: [
async (request, _options, response) => {
if (response.status !== 401 || isRefreshRequest(request) || isAuthSessionRequest(request)) {
if (response.status !== 401 || isAuthLoginRequest(request)) {
return response;
}
const refreshed = await refreshSession();
if (!refreshed) {
clearSessionAndRedirectToLogin();
return response;
}
const retried = await ky(request, { credentials: "include" });
if (retried.status === 401) {
clearSessionAndRedirectToLogin();
}
return retried;
clearSessionAndRedirectToLogin();
return response;
},
],
},

View file

@ -0,0 +1,5 @@
import { Navigate } from "react-router-dom";
export default function CalendarLayout() {
return <Navigate to="/" replace />;
}

View file

@ -1,20 +1,3 @@
import {
FaAddressBook,
FaBell,
FaCalendarAlt,
FaClipboardList,
FaCog,
FaCubes,
FaDollarSign,
FaFileInvoiceDollar,
FaMapMarkerAlt,
FaMoneyCheckAlt,
FaTachometerAlt,
FaTools,
FaTruck,
FaUserTie,
} from "react-icons/fa";
import type { IconType } from "react-icons";
import { NavLink } from "react-router-dom";
import {
Box,
@ -26,60 +9,12 @@ import {
Typography,
} from "@mui/material";
import { navGroups, type NavItemDef } from "@/config/menu";
import { cn } from "@/lib/utils";
const SIDEBAR_WIDTH = 220;
const SIDEBAR_COLLAPSED_WIDTH = 56;
type NavItemDef = {
label: string;
icon: IconType;
path: string;
};
const navGroups: { title: string; items: NavItemDef[] }[] = [
{
title: "Overview",
items: [
{ label: "Dashboard", icon: FaTachometerAlt, path: "/" },
{ label: "Calendar", icon: FaCalendarAlt, path: "/calendar" },
],
},
{
title: "Operations",
items: [
{ label: "Work Orders", icon: FaTools, path: "/workorders" },
{ label: "Uplifts", icon: FaDollarSign, path: "/uplifts" },
{ label: "Follow Ups", icon: FaBell, path: "/followups" },
{ label: "PM Schedules", icon: FaClipboardList, path: "/pmschedules" },
],
},
{
title: "Vendors",
items: [
{ label: "Vendors", icon: FaTruck, path: "/vendors" },
{ label: "Vendor POs", icon: FaFileInvoiceDollar, path: "/vendor-pos" },
],
},
{
title: "Customers & Sites",
items: [
{ label: "Accounts", icon: FaMoneyCheckAlt, path: "/accounts" },
{ label: "Contacts", icon: FaAddressBook, path: "/contacts" },
{ label: "Locations", icon: FaMapMarkerAlt, path: "/locations" },
{ label: "Assets", icon: FaCubes, path: "/assets" },
],
},
{
title: "Internal",
items: [{ label: "Employees", icon: FaUserTie, path: "/employees" }],
},
{
title: "System",
items: [{ label: "Settings", icon: FaCog, path: "/settings/dropdowns" }],
},
];
type NavItemProps = NavItemDef & {
collapsed: boolean;
end?: boolean;

View file

@ -38,8 +38,8 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
logout();
};
const userInitials = user?.name
? user.name
const userInitials = user?.fullname
? user.fullname
.split(" ")
.map((part) => part[0])
.join("")
@ -104,7 +104,7 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
/>
<IconButton
title={user?.name || "User"}
title="Account"
onClick={(event) => setUserAnchor(event.currentTarget)}
sx={{
color: "var(--color-header-text-muted)",
@ -168,7 +168,6 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
{userInitials}
</Avatar>
<Box>
<Typography variant="subtitle2">{user?.name || "User"}</Typography>
<Typography variant="caption" color="text.secondary">
{user?.email || ""}
</Typography>

67
src/config/menu.ts Normal file
View file

@ -0,0 +1,67 @@
import {
FaAddressBook,
FaBell,
FaClipboardList,
FaCog,
FaCubes,
FaDollarSign,
FaFileInvoiceDollar,
FaMapMarkerAlt,
FaMoneyCheckAlt,
FaTachometerAlt,
FaTools,
FaTruck,
FaUserTie,
} from "react-icons/fa";
import type { IconType } from "react-icons";
export type NavItemDef = {
label: string;
icon: IconType;
path: string;
};
export type NavGroup = {
title: string;
items: NavItemDef[];
};
export const navGroups: NavGroup[] = [
{
title: "Overview",
items: [{ label: "Dashboard", icon: FaTachometerAlt, path: "/" }],
},
{
title: "Operations",
items: [
{ label: "Work Orders", icon: FaTools, path: "/workorders" },
{ label: "Uplifts", icon: FaDollarSign, path: "/uplifts" },
{ label: "Follow Ups", icon: FaBell, path: "/followups" },
{ label: "PM Schedules", icon: FaClipboardList, path: "/pmschedules" },
],
},
{
title: "Vendors",
items: [
{ label: "Vendors", icon: FaTruck, path: "/vendors" },
{ label: "Vendor POs", icon: FaFileInvoiceDollar, path: "/vendor-pos" },
],
},
{
title: "Customers & Sites",
items: [
{ label: "Accounts", icon: FaMoneyCheckAlt, path: "/accounts" },
{ label: "Contacts", icon: FaAddressBook, path: "/contacts" },
{ label: "Locations", icon: FaMapMarkerAlt, path: "/locations" },
{ label: "Assets", icon: FaCubes, path: "/assets" },
],
},
{
title: "Internal",
items: [{ label: "Employees", icon: FaUserTie, path: "/employees" }],
},
{
title: "System",
items: [{ label: "Settings", icon: FaCog, path: "/settings/dropdowns" }],
},
];

View file

@ -1,5 +1,5 @@
import { API_PATHS } from "@/api/api-paths";
import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
import { apiGet, apiPost } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
mapAccount,
@ -38,24 +38,31 @@ export const accountsApi = {
},
getById: async (id: string | number): Promise<Account> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.accounts}/${id}`);
const data = await apiGet<unknown>(API_PATHS.account.getById, {
searchParams: { id },
});
return mapAccount(handleApiResponse(data));
},
create: async (payload: unknown): Promise<Account> => {
const data = await apiPost<unknown>(API_PATHS.rest.accounts, mapAccountToBackend(payload));
return mapAccount(handleApiResponse(data));
},
update: async (id: string | number, payload: unknown): Promise<Account> => {
const data = await apiPut<unknown>(
`${API_PATHS.rest.accounts}/${id}`,
const data = await apiPost<unknown>(
API_PATHS.account.addAccounts,
mapAccountToBackend(payload),
);
return mapAccount(handleApiResponse(data));
},
update: async (id: string | number, payload: unknown): Promise<Account> => {
const data = await apiPost<unknown>(
API_PATHS.account.editAccounts,
mapAccountToBackend({ ...(payload as object), id }),
);
return mapAccount(handleApiResponse(data));
},
delete: async (id: string | number): Promise<void> => {
await apiDelete<unknown>(`${API_PATHS.rest.accounts}/${id}`);
await apiGet<unknown>(API_PATHS.account.deleteAccounts, {
searchParams: { id },
});
},
};

View file

@ -1,13 +1,12 @@
/**
* Auth API contract (cookie-based session):
* - POST Authentication/Login — sets httpOnly session cookies
* - GET Authentication/GetCurrentUser — returns active user from cookie
* - POST Authentication/Logout — clears session cookies
* - POST Authentication/Refresh — refreshes session cookie on 401
* Auth API contract (JWT):
* - POST Authentication/Login — returns token + user fields; persisted in localStorage
* - Session restored from stored login payload (no GetCurrentUser)
*/
import { API_PATHS } from "@/api/api-paths";
import { api, apiPost } from "@/api/api";
import { apiPost } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import { clearAuth, getAuthUser, isSessionValid, setAuthUser } from "@/lib/auth/auth-storage";
import type {
AuthUser,
ChangePasswordPayload,
@ -17,29 +16,24 @@ import type {
export async function login(credentials: LoginCredentials): Promise<AuthUser> {
const response = await apiPost<unknown>(API_PATHS.authentication.login, credentials);
return handleApiResponse<AuthUser>(response);
const user = handleApiResponse<AuthUser>(response);
setAuthUser(user);
return getAuthUser() ?? user;
}
export async function logout(): Promise<void> {
await apiPost(API_PATHS.authentication.logout);
clearAuth();
}
export async function getSession(): Promise<AuthUser | null> {
try {
const response = await api.get(API_PATHS.authentication.getCurrentUser, {
throwHttpErrors: false,
});
const user = getAuthUser();
if (!response.ok) {
return null;
}
const payload = (await response.json()) as unknown;
const user = handleApiResponse<AuthUser | null>(payload);
return user ?? null;
} catch {
if (!user || !isSessionValid(user)) {
clearAuth();
return null;
}
return user;
}
export async function changePassword(data: ChangePasswordPayload): Promise<unknown> {

View file

@ -1,11 +1,12 @@
export interface AuthUser {
id?: string | number;
token: string;
expiration: string;
email: string;
userRoles: string;
phoneNumber: string;
fullname: string;
id: string | number;
name?: string;
email?: string;
username?: string;
role?: string;
roles?: string[];
[key: string]: unknown;
}
export interface LoginCredentials {

View file

@ -27,8 +27,8 @@ export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials>
return useMutation({
mutationFn: (credentials: LoginCredentials) => authApi.login(credentials),
meta: { suppressErrorToast: true },
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session() });
onSuccess: (user) => {
queryClient.setQueryData(queryKeys.auth.session(), user);
toast.success("Login successful!");
navigate("/");
},

View file

@ -1,5 +1,5 @@
import { API_PATHS } from "@/api/api-paths";
import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
import { apiGet, apiPost } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
mapContact,
@ -38,24 +38,31 @@ export const contactsApi = {
},
getById: async (id: string | number): Promise<Contact> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.contacts}/${id}`);
const data = await apiGet<unknown>(API_PATHS.contact.getById, {
searchParams: { id },
});
return mapContact(handleApiResponse(data));
},
create: async (payload: unknown): Promise<Contact> => {
const data = await apiPost<unknown>(API_PATHS.rest.contacts, mapContactToBackend(payload));
return mapContact(handleApiResponse(data));
},
update: async (id: string | number, payload: unknown): Promise<Contact> => {
const data = await apiPut<unknown>(
`${API_PATHS.rest.contacts}/${id}`,
const data = await apiPost<unknown>(
API_PATHS.contact.addContacts,
mapContactToBackend(payload),
);
return mapContact(handleApiResponse(data));
},
update: async (id: string | number, payload: unknown): Promise<Contact> => {
const data = await apiPost<unknown>(
API_PATHS.contact.editContacts,
mapContactToBackend({ ...(payload as object), id }),
);
return mapContact(handleApiResponse(data));
},
delete: async (id: string | number): Promise<void> => {
await apiDelete<unknown>(`${API_PATHS.rest.contacts}/${id}`);
await apiGet<unknown>(API_PATHS.contact.deleteContacts, {
searchParams: { id },
});
},
};

View file

@ -1,5 +1,5 @@
import { API_PATHS } from "@/api/api-paths";
import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
import { apiGet, apiPost } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
mapEmployeeListItems,
@ -38,25 +38,32 @@ export const employeesApi = {
},
getById: async (id: string | number): Promise<Employee | null> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.employees}/${id}`);
const data = await apiGet<unknown>(API_PATHS.employee.getById, {
searchParams: { id },
});
return transformEmployee(handleApiResponse(data));
},
create: async (payload: Employee): Promise<Employee | null> => {
const data = await apiPost<unknown>(API_PATHS.rest.employees, transformToBackend(payload));
const data = await apiPost<unknown>(
API_PATHS.employee.addEmployees,
transformToBackend(payload),
);
return transformEmployee(handleApiResponse(data));
},
update: async (id: string | number, payload: Employee): Promise<Employee | null> => {
const data = await apiPut<unknown>(
`${API_PATHS.rest.employees}/${id}`,
const data = await apiPost<unknown>(
API_PATHS.employee.editEmployees,
transformToBackend({ ...payload, id }),
);
return transformEmployee(handleApiResponse(data));
},
delete: async (id: string | number): Promise<void> => {
await apiDelete<unknown>(`${API_PATHS.rest.employees}/${id}`);
await apiGet<unknown>(API_PATHS.employee.deleteEmployees, {
searchParams: { id },
});
},
getDropdown: () => apiGet<unknown>(API_PATHS.employee.dropdown),

View file

@ -1,5 +1,5 @@
import { API_PATHS } from "@/api/api-paths";
import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
import { apiDelete, apiGet, apiPost } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
mapDispatchComment,
@ -99,7 +99,7 @@ export const workOrdersApi = {
},
getById: async (id: string | number): Promise<WorkOrderDetail> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.workOrders}/${id}`);
const data = await apiGet<unknown>(API_PATHS.workOrder.getById, { searchParams: { id } });
return mapWorkOrderDetail(handleApiResponse(data));
},
@ -107,7 +107,7 @@ export const workOrdersApi = {
values: WorkOrderFormSchemaValues | WorkOrderFormValues,
): Promise<WorkOrderDetail> => {
const payload = mapWorkOrderFormToCreatePayload(values);
const data = await apiPost<unknown>(API_PATHS.rest.workOrders, payload);
const data = await apiPost<unknown>(API_PATHS.workOrder.addWorkorder, payload);
return mapWorkOrderDetail(handleApiResponse(data));
},
@ -116,12 +116,12 @@ export const workOrdersApi = {
values: WorkOrderFormSchemaValues | WorkOrderFormValues,
): Promise<WorkOrderDetail> => {
const payload = mapWorkOrderFormToUpdatePayload(id, values);
const data = await apiPut<unknown>(`${API_PATHS.rest.workOrders}/${id}`, payload);
const data = await apiPost<unknown>(API_PATHS.workOrder.editWorkorder, payload);
return mapWorkOrderDetail(handleApiResponse(data));
},
delete: async (id: string | number): Promise<void> => {
await apiDelete<unknown>(`${API_PATHS.rest.workOrders}/${id}`);
await apiDelete<unknown>(API_PATHS.workOrder.deleteWorkorder, { searchParams: { id } });
},
addComment: async (

View file

@ -0,0 +1,68 @@
import type { AuthUser } from "@/domain/auth/types/auth-user";
const AUTH_STORAGE_KEY = "auth";
function parseExpiration(expiration: string): number | null {
const asNumber = Number(expiration);
if (!Number.isNaN(asNumber) && asNumber > 0) {
return asNumber > 1e12 ? asNumber : asNumber * 1000;
}
const parsed = Date.parse(expiration);
return Number.isNaN(parsed) ? null : parsed;
}
export function setAuthUser(user: AuthUser): void {
if (typeof window === "undefined") {
return;
}
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(user));
}
export function getAuthUser(): AuthUser | null {
if (typeof window === "undefined") {
return null;
}
const raw = localStorage.getItem(AUTH_STORAGE_KEY);
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as AuthUser;
} catch {
return null;
}
}
export function getAccessToken(): string | null {
const user = getAuthUser();
if (!user?.token || typeof user.token !== "string") {
return null;
}
return `Bearer ${user.token}`;
}
export function clearAuth(): void {
if (typeof window === "undefined") {
return;
}
localStorage.removeItem(AUTH_STORAGE_KEY);
}
export function isSessionValid(user: AuthUser | null): boolean {
if (!user?.token || !user.expiration) {
return false;
}
const expiresAt = parseExpiration(user.expiration);
if (expiresAt === null) {
return false;
}
return Date.now() < expiresAt;
}

View file

@ -5,12 +5,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import LoginPage from "@/app/(auth)/login";
import * as authApi from "@/domain/auth/api/auth-api";
import type { AuthUser } from "@/domain/auth/types/auth-user";
import { renderWithProviders } from "@/test/test-utils";
const { toastSuccessMock } = vi.hoisted(() => ({
toastSuccessMock: vi.fn(),
}));
const TEST_USER: AuthUser = {
token: "test-jwt-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "user@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Test User",
id: 1,
};
vi.mock("@/domain/auth/api/auth-api", () => ({
getSession: vi.fn(),
login: vi.fn(),
@ -61,10 +72,7 @@ describe("LoginPage integration", () => {
});
it("logs in successfully and navigates to dashboard", async () => {
const userPayload = { id: 1, email: "user@seahavenind.com" };
vi.mocked(authApi.login).mockResolvedValue(userPayload);
vi.mocked(authApi.getSession).mockResolvedValue(userPayload);
vi.mocked(authApi.login).mockResolvedValue(TEST_USER);
renderWithProviders(<LoginRoutes />, { route: "/login" });
@ -116,7 +124,7 @@ describe("LoginPage integration", () => {
});
it("shows loading state while login is pending", async () => {
let resolveLogin: (value: { id: number; email: string }) => void = () => {};
let resolveLogin: (value: AuthUser) => void = () => {};
vi.mocked(authApi.login).mockImplementation(
() =>
@ -124,7 +132,6 @@ describe("LoginPage integration", () => {
resolveLogin = resolve;
}),
);
vi.mocked(authApi.getSession).mockResolvedValue({ id: 1, email: "user@seahavenind.com" });
renderWithProviders(<LoginRoutes />, { route: "/login" });
@ -142,7 +149,7 @@ describe("LoginPage integration", () => {
expect(within(form!).getByRole("button", { name: /signing in/i })).toBeDisabled();
});
resolveLogin({ id: 1, email: "user@seahavenind.com" });
resolveLogin(TEST_USER);
await waitFor(() => {
expect(screen.getByTestId("dashboard")).toBeInTheDocument();

View file

@ -4,9 +4,20 @@ import { describe, expect, it, vi } from "vitest";
import { GuestRoute } from "@/components/auth/guest-route";
import { ProtectedRoute } from "@/components/auth/protected-route";
import type { AuthUser } from "@/domain/auth/types/auth-user";
import { AuthContext, type AuthContextValue } from "@/providers/auth-context";
import { renderWithProviders } from "@/test/test-utils";
const mockAuthUser: AuthUser = {
token: "test-jwt-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "user@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Test User",
id: 1,
};
function createAuthContextValue(overrides: Partial<AuthContextValue> = {}): AuthContextValue {
return {
user: null,
@ -51,7 +62,7 @@ describe("ProtectedRoute", () => {
</ProtectedRoute>,
createAuthContextValue({
isAuthenticated: true,
user: { id: 1, email: "user@seahavenind.com" },
user: mockAuthUser,
}),
"/workorders",
);
@ -81,7 +92,7 @@ describe("GuestRoute", () => {
</GuestRoute>,
createAuthContextValue({
isAuthenticated: true,
user: { id: 1, email: "user@seahavenind.com" },
user: mockAuthUser,
}),
"/login",
);