From f806e0a483599b60788a943b71fb6fe6aa897a6d Mon Sep 17 00:00:00 2001 From: dmolinari Date: Tue, 14 Apr 2026 13:48:50 -0300 Subject: [PATCH] =?UTF-8?q?test(web):=20authStore=20TDD=20=E2=80=94=20refr?= =?UTF-8?q?eshToken,=20expiresAt,=20clearAuth,=20updateAccess,=20logout=20?= =?UTF-8?q?async?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/web/src/stores/authStore.ts | 43 +++++-- src/web/src/tests/stores/authStore.test.ts | 138 +++++++++++++++++++-- 2 files changed, 164 insertions(+), 17 deletions(-) diff --git a/src/web/src/stores/authStore.ts b/src/web/src/stores/authStore.ts index 99f3c2e..cfa8a68 100644 --- a/src/web/src/stores/authStore.ts +++ b/src/web/src/stores/authStore.ts @@ -12,34 +12,59 @@ interface SetAuthPayload { user: AuthUser accessToken: string refreshToken: string - expiresIn: number + expiresIn: number // seconds from backend } interface AuthState { user: AuthUser | null accessToken: string | null + refreshToken: string | null + expiresAt: number | null // ms epoch UTC setAuth: (payload: SetAuthPayload) => void - logout: () => void + updateAccess: (accessToken: string, refreshToken: string, expiresAt: number) => void + clearAuth: () => void + logout: () => Promise } export const useAuthStore = create()( persist( - (set) => ({ + (set, get) => ({ user: null, accessToken: null, + refreshToken: null, + expiresAt: null, - setAuth: (payload: SetAuthPayload) => { + setAuth: (payload) => set({ user: payload.user, accessToken: payload.accessToken, - }) - }, + refreshToken: payload.refreshToken, + expiresAt: Date.now() + payload.expiresIn * 1000, + }), - logout: () => { + updateAccess: (accessToken, refreshToken, expiresAt) => + set({ accessToken, refreshToken, expiresAt }), + + clearAuth: () => set({ user: 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()( partialize: (state) => ({ user: state.user, accessToken: state.accessToken, + refreshToken: state.refreshToken, + expiresAt: state.expiresAt, }), }, ), diff --git a/src/web/src/tests/stores/authStore.test.ts b/src/web/src/tests/stores/authStore.test.ts index 995c458..6fbb09d 100644 --- a/src/web/src/tests/stores/authStore.test.ts +++ b/src/web/src/tests/stores/authStore.test.ts @@ -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' describe('authStore', () => { beforeEach(() => { // Reset store state before each test - useAuthStore.getState().logout() + useAuthStore.setState({ + user: null, + accessToken: null, + refreshToken: null, + expiresAt: null, + }) localStorage.clear() }) + afterEach(() => { + vi.restoreAllMocks() + }) + describe('initial state', () => { it('starts with null user and null accessToken', () => { const state = useAuthStore.getState() @@ -48,11 +57,123 @@ describe('authStore', () => { expect(parsed.state.accessToken).toBe(payload.accessToken) 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', () => { - it('clears user and accessToken from state', () => { - // Setup: set auth first + describe('clearAuth', () => { + it('clearAuth_removesAllFields', () => { + 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({ user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, accessToken: 'some-token', @@ -60,14 +181,14 @@ describe('authStore', () => { expiresIn: 3600, }) - useAuthStore.getState().logout() + await useAuthStore.getState().logout() const state = useAuthStore.getState() expect(state.user).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({ user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, accessToken: 'some-token', @@ -75,10 +196,9 @@ describe('authStore', () => { expiresIn: 3600, }) - useAuthStore.getState().logout() + await useAuthStore.getState().logout() const stored = localStorage.getItem('auth-storage') - // After logout the persisted state should have null user/token if (stored !== null) { const parsed = JSON.parse(stored) expect(parsed.state.user).toBeNull()