test(web): authStore TDD — refreshToken, expiresAt, clearAuth, updateAccess, logout async

This commit is contained in:
2026-04-14 13:48:50 -03:00
parent f1d4ea0047
commit f806e0a483
2 changed files with 164 additions and 17 deletions

View File

@@ -12,34 +12,59 @@ interface SetAuthPayload {
user: AuthUser user: AuthUser
accessToken: string accessToken: string
refreshToken: string refreshToken: string
expiresIn: number expiresIn: number // seconds from backend
} }
interface AuthState { interface AuthState {
user: AuthUser | null user: AuthUser | null
accessToken: string | null accessToken: string | null
refreshToken: string | null
expiresAt: number | null // ms epoch UTC
setAuth: (payload: SetAuthPayload) => void setAuth: (payload: SetAuthPayload) => void
logout: () => void updateAccess: (accessToken: string, refreshToken: string, expiresAt: number) => void
clearAuth: () => void
logout: () => Promise<void>
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
persist( persist(
(set) => ({ (set, get) => ({
user: null, user: null,
accessToken: null, accessToken: null,
refreshToken: null,
expiresAt: null,
setAuth: (payload: SetAuthPayload) => { setAuth: (payload) =>
set({ set({
user: payload.user, user: payload.user,
accessToken: payload.accessToken, accessToken: payload.accessToken,
}) refreshToken: payload.refreshToken,
}, expiresAt: Date.now() + payload.expiresIn * 1000,
}),
logout: () => { updateAccess: (accessToken, refreshToken, expiresAt) =>
set({ accessToken, refreshToken, expiresAt }),
clearAuth: () =>
set({ set({
user: null, user: null,
accessToken: null, accessToken: null,
}) refreshToken: null,
expiresAt: null,
}),
logout: async () => {
const { accessToken, clearAuth } = get()
if (accessToken) {
try {
// Lazy import to break circular dependency with axiosClient
const { logout: apiLogout } = await import('@/features/auth/api/authApi')
await apiLogout()
} catch {
// Ignore API errors — local logout is always safe
}
}
clearAuth()
}, },
}), }),
{ {
@@ -47,6 +72,8 @@ export const useAuthStore = create<AuthState>()(
partialize: (state) => ({ partialize: (state) => ({
user: state.user, user: state.user,
accessToken: state.accessToken, accessToken: state.accessToken,
refreshToken: state.refreshToken,
expiresAt: state.expiresAt,
}), }),
}, },
), ),

View File

@@ -1,13 +1,22 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { useAuthStore } from '../../stores/authStore' import { useAuthStore } from '../../stores/authStore'
describe('authStore', () => { describe('authStore', () => {
beforeEach(() => { beforeEach(() => {
// Reset store state before each test // Reset store state before each test
useAuthStore.getState().logout() useAuthStore.setState({
user: null,
accessToken: null,
refreshToken: null,
expiresAt: null,
})
localStorage.clear() localStorage.clear()
}) })
afterEach(() => {
vi.restoreAllMocks()
})
describe('initial state', () => { describe('initial state', () => {
it('starts with null user and null accessToken', () => { it('starts with null user and null accessToken', () => {
const state = useAuthStore.getState() const state = useAuthStore.getState()
@@ -48,11 +57,123 @@ describe('authStore', () => {
expect(parsed.state.accessToken).toBe(payload.accessToken) expect(parsed.state.accessToken).toBe(payload.accessToken)
expect(parsed.state.user.username).toBe('admin') expect(parsed.state.user.username).toBe('admin')
}) })
it('setAuth_persistsRefreshTokenAndExpiresAt', () => {
const before = Date.now()
const payload = {
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'access-token-abc',
refreshToken: 'opaque-refresh-xyz',
expiresIn: 3600,
}
useAuthStore.getState().setAuth(payload)
const after = Date.now()
const state = useAuthStore.getState()
expect(state.refreshToken).toBe('opaque-refresh-xyz')
expect(state.expiresAt).not.toBeNull()
// expiresAt should be ~Date.now() + 3600*1000
expect(state.expiresAt!).toBeGreaterThanOrEqual(before + 3600 * 1000)
expect(state.expiresAt!).toBeLessThanOrEqual(after + 3600 * 1000)
// Should also be persisted in localStorage
const stored = localStorage.getItem('auth-storage')
const parsed = JSON.parse(stored!)
expect(parsed.state.refreshToken).toBe('opaque-refresh-xyz')
expect(parsed.state.expiresAt).toBeGreaterThan(0)
})
}) })
describe('logout', () => { describe('clearAuth', () => {
it('clears user and accessToken from state', () => { it('clearAuth_removesAllFields', () => {
// Setup: set auth first useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
})
useAuthStore.getState().clearAuth()
const state = useAuthStore.getState()
expect(state.user).toBeNull()
expect(state.accessToken).toBeNull()
expect(state.refreshToken).toBeNull()
expect(state.expiresAt).toBeNull()
})
})
describe('updateAccess', () => {
it('updateAccess_updatesOnlyTokens_preservesUser', () => {
const originalUser = { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }
useAuthStore.getState().setAuth({
user: originalUser,
accessToken: 'old-access',
refreshToken: 'old-refresh',
expiresIn: 3600,
})
const newExpiresAt = Date.now() + 7200 * 1000
useAuthStore.getState().updateAccess('new-access', 'new-refresh', newExpiresAt)
const state = useAuthStore.getState()
expect(state.accessToken).toBe('new-access')
expect(state.refreshToken).toBe('new-refresh')
expect(state.expiresAt).toBe(newExpiresAt)
// user should be preserved
expect(state.user).toEqual(originalUser)
})
})
describe('logout (async)', () => {
it('logout_callsApi_thenClearsAuth', async () => {
// Set up auth state with a token so logout() will try to call the API
useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
})
// logout() does a lazy import of authApi and calls logout()
// The API call may succeed or fail (we don't control the real import here),
// but clearAuth() is ALWAYS called after regardless.
await useAuthStore.getState().logout()
const state = useAuthStore.getState()
expect(state.user).toBeNull()
expect(state.accessToken).toBeNull()
expect(state.refreshToken).toBeNull()
expect(state.expiresAt).toBeNull()
})
it('logout_apiFails_stillClearsAuth', async () => {
useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
})
// Should NOT throw even if the dynamic import fails
// (We test this by verifying clearAuth is always called)
let threw = false
try {
await useAuthStore.getState().logout()
} catch {
threw = true
}
expect(threw).toBe(false)
const state = useAuthStore.getState()
expect(state.user).toBeNull()
expect(state.accessToken).toBeNull()
})
})
describe('legacy logout compatibility', () => {
it('clears user and accessToken from state', async () => {
useAuthStore.getState().setAuth({ useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'some-token', accessToken: 'some-token',
@@ -60,14 +181,14 @@ describe('authStore', () => {
expiresIn: 3600, expiresIn: 3600,
}) })
useAuthStore.getState().logout() await useAuthStore.getState().logout()
const state = useAuthStore.getState() const state = useAuthStore.getState()
expect(state.user).toBeNull() expect(state.user).toBeNull()
expect(state.accessToken).toBeNull() expect(state.accessToken).toBeNull()
}) })
it('removes auth-storage from localStorage on logout', () => { it('removes auth-storage from localStorage on logout', async () => {
useAuthStore.getState().setAuth({ useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'some-token', accessToken: 'some-token',
@@ -75,10 +196,9 @@ describe('authStore', () => {
expiresIn: 3600, expiresIn: 3600,
}) })
useAuthStore.getState().logout() await useAuthStore.getState().logout()
const stored = localStorage.getItem('auth-storage') const stored = localStorage.getItem('auth-storage')
// After logout the persisted state should have null user/token
if (stored !== null) { if (stored !== null) {
const parsed = JSON.parse(stored) const parsed = JSON.parse(stored)
expect(parsed.state.user).toBeNull() expect(parsed.state.user).toBeNull()