feat: add useAuth hook with Supabase Auth session management

Implements useAuth with getSession, onAuthStateChange, login, and logout.
All 6 TDD tests pass, using vi.hoisted to resolve Vitest mock hoisting.
This commit is contained in:
2026-05-07 20:07:53 +03:00
parent cf6ef372c1
commit 418b4b7cac
2 changed files with 130 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
import { renderHook, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
const {
mockUnsubscribe,
mockGetSession,
mockOnAuthStateChange,
mockSignInWithPassword,
mockSignOut,
} = vi.hoisted(() => ({
mockUnsubscribe: vi.fn(),
mockGetSession: vi.fn(),
mockOnAuthStateChange: vi.fn(() => ({
data: { subscription: { unsubscribe: vi.fn() } },
})),
mockSignInWithPassword: vi.fn(),
mockSignOut: vi.fn(),
}));
vi.mock('../../../lib/supabase', () => ({
supabase: {
auth: {
getSession: mockGetSession,
onAuthStateChange: mockOnAuthStateChange,
signInWithPassword: mockSignInWithPassword,
signOut: mockSignOut,
},
},
}));
import { useAuth } from '../useAuth';
describe('useAuth', () => {
beforeEach(() => {
vi.clearAllMocks();
mockOnAuthStateChange.mockReturnValue({
data: { subscription: { unsubscribe: mockUnsubscribe } },
});
});
it('starts with loading=true and session=null', () => {
mockGetSession.mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useAuth());
expect(result.current.loading).toBe(true);
expect(result.current.session).toBe(null);
});
it('sets loading=false and session after getSession resolves', async () => {
const fakeSession = { user: { email: 'admin@test.com' } };
mockGetSession.mockResolvedValue({ data: { session: fakeSession } });
const { result } = renderHook(() => useAuth());
await act(async () => {});
expect(result.current.loading).toBe(false);
expect(result.current.session).toEqual(fakeSession);
});
it('login calls signInWithPassword with email and password', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } });
mockSignInWithPassword.mockResolvedValue({ error: null });
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.login('admin@test.com', 'secret');
});
expect(mockSignInWithPassword).toHaveBeenCalledWith({
email: 'admin@test.com',
password: 'secret',
});
});
it('login returns error when credentials are wrong', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } });
const fakeError = new Error('Invalid credentials');
mockSignInWithPassword.mockResolvedValue({ error: fakeError });
const { result } = renderHook(() => useAuth());
let loginResult;
await act(async () => {
loginResult = await result.current.login('bad@test.com', 'wrong');
});
expect(loginResult.error).toBe(fakeError);
});
it('logout calls signOut', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } });
mockSignOut.mockResolvedValue({});
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.logout();
});
expect(mockSignOut).toHaveBeenCalled();
});
it('unsubscribes on unmount', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } });
const { unmount } = renderHook(() => useAuth());
await act(async () => {});
unmount();
expect(mockUnsubscribe).toHaveBeenCalled();
});
});
+31
View File
@@ -0,0 +1,31 @@
import { useState, useEffect } from 'react';
import { supabase } from '../../lib/supabase';
export function useAuth() {
const [session, setSession] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setLoading(false);
});
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
});
return () => subscription.unsubscribe();
}, []);
const login = async (email, password) => {
const { error } = await supabase.auth.signInWithPassword({ email, password });
return { error };
};
const logout = async () => {
await supabase.auth.signOut();
};
return { session, loading, login, logout };
}