diff --git a/src/features/auth/api/calls/index.ts b/src/features/auth/api/calls/index.ts index d030e96..3673624 100644 --- a/src/features/auth/api/calls/index.ts +++ b/src/features/auth/api/calls/index.ts @@ -1,4 +1,4 @@ export * from "./login"; export * from "./register"; export * from "./logout"; -export * from "./refresh"; +export * from "../../../../shared/api/calls/refresh"; diff --git a/src/features/auth/api/calls/login/login.mock.ts b/src/features/auth/api/calls/login/login.mock.ts index 4bcce0b..a156a31 100644 --- a/src/features/auth/api/calls/login/login.mock.ts +++ b/src/features/auth/api/calls/login/login.mock.ts @@ -1,6 +1,6 @@ import { http, HttpResponse } from "msw"; import type { AuthData } from "../../../model/types/service"; -import { BASE_URL } from "shared/config"; +import { BASE_URL } from "shared/api"; import { LOGIN_API_ROUTE } from "./login"; import { MOCK_AUTH_RESPONSE, MOCK_EMAIL, MOCK_PASSWORD } from "../mocks"; diff --git a/src/features/auth/api/calls/login/login.spec.ts b/src/features/auth/api/calls/login/login.spec.ts index 7db683b..340de24 100644 --- a/src/features/auth/api/calls/login/login.spec.ts +++ b/src/features/auth/api/calls/login/login.spec.ts @@ -1,12 +1,8 @@ import { setupServer } from "msw/node"; import { login } from "./login"; import { loginMock } from "./login.mock"; -import { - MOCK_EMAIL, - MOCK_EXISTING_USER, - MOCK_PASSWORD, - MOCK_TOKEN, -} from "../mocks"; +import { MOCK_EMAIL, MOCK_EXISTING_USER, MOCK_PASSWORD } from "../mocks"; +import { MOCK_TOKEN } from "shared/api"; const server = setupServer(loginMock); diff --git a/src/features/auth/api/calls/logout/logout.mock.ts b/src/features/auth/api/calls/logout/logout.mock.ts index 68007b3..b948bf4 100644 --- a/src/features/auth/api/calls/logout/logout.mock.ts +++ b/src/features/auth/api/calls/logout/logout.mock.ts @@ -1,5 +1,5 @@ import { http, HttpResponse } from "msw"; -import { BASE_URL } from "shared/config"; +import { BASE_URL } from "shared/api"; import { LOGOUT_API_ROUTE } from "./logout"; const LOGOUT_URL = `${BASE_URL}/${LOGOUT_API_ROUTE}`; diff --git a/src/features/auth/api/calls/mocks.ts b/src/features/auth/api/calls/mocks.ts index 3100d7d..9fda5a9 100644 --- a/src/features/auth/api/calls/mocks.ts +++ b/src/features/auth/api/calls/mocks.ts @@ -1,8 +1,7 @@ import type { User } from "entities/User"; import type { AuthResponse } from "../../model"; +import { MOCK_TOKEN } from "shared/api"; -export const MOCK_TOKEN = "mock.access.token"; -export const MOCK_FRESH_TOKEN = "mock.fresh.access.token"; export const MOCK_EMAIL = "test@test.com"; export const MOCK_NEW_EMAIL = "new@test.com"; export const MOCK_PASSWORD = "password"; diff --git a/src/features/auth/api/calls/refresh/refresh.mock.ts b/src/features/auth/api/calls/refresh/refresh.mock.ts deleted file mode 100644 index 590e17e..0000000 --- a/src/features/auth/api/calls/refresh/refresh.mock.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { http, HttpResponse } from "msw"; -import { BASE_URL } from "shared/config"; -import { REFRESH_API_ROUTE } from "./refresh"; -import { MOCK_EXISTING_USER, MOCK_FRESH_TOKEN, MOCK_TOKEN } from "../mocks"; - -const REFRESH_URL = `${BASE_URL}/${REFRESH_API_ROUTE}`; - -/** - * Msw interceptor. Mocks the refresh endpoint response. - * Validates the Authorization header — returns a fresh token on success, 401 on expired/missing session. - */ -export const refreshMock = http.post(REFRESH_URL, ({ request }) => { - const authHeader = request.headers.get("Authorization"); - - if (authHeader === `Bearer ${MOCK_TOKEN}`) { - return HttpResponse.json({ - user: MOCK_EXISTING_USER, - accessToken: MOCK_FRESH_TOKEN, - }); - } - - return HttpResponse.json({ message: "Session expired" }, { status: 401 }); -}); diff --git a/src/features/auth/api/calls/refresh/refresh.ts b/src/features/auth/api/calls/refresh/refresh.ts deleted file mode 100644 index c5bc1dc..0000000 --- a/src/features/auth/api/calls/refresh/refresh.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { AuthResponse } from "../../../model/types/service"; -import { authHttpClient } from "../../config/authApi/authApi"; - -export const REFRESH_API_ROUTE = "auth/refresh"; - -export function refresh() { - return authHttpClient.post(REFRESH_API_ROUTE).json(); -} diff --git a/src/features/auth/api/calls/register/register.mock.ts b/src/features/auth/api/calls/register/register.mock.ts index 44e7ec1..4f0fdfa 100644 --- a/src/features/auth/api/calls/register/register.mock.ts +++ b/src/features/auth/api/calls/register/register.mock.ts @@ -1,8 +1,8 @@ import { http, HttpResponse } from "msw"; import type { AuthData } from "../../../model/types/service"; -import { BASE_URL } from "shared/config"; +import { BASE_URL, MOCK_TOKEN } from "shared/api"; import { REGISTER_API_ROUTE } from "./register"; -import { MOCK_EMAIL, MOCK_TOKEN } from "../mocks"; +import { MOCK_EMAIL } from "../mocks"; const REGISTER_URL = `${BASE_URL}/${REGISTER_API_ROUTE}`; diff --git a/src/features/auth/api/calls/register/register.spec.ts b/src/features/auth/api/calls/register/register.spec.ts index 4ffc572..c53b4af 100644 --- a/src/features/auth/api/calls/register/register.spec.ts +++ b/src/features/auth/api/calls/register/register.spec.ts @@ -6,8 +6,8 @@ import { MOCK_NEW_EMAIL, MOCK_NEW_USER, MOCK_PASSWORD, - MOCK_TOKEN, } from "../mocks"; +import { MOCK_TOKEN } from "shared/api"; const server = setupServer(registerMock); diff --git a/src/features/auth/api/config/authApi/authApi.ts b/src/features/auth/api/config/authApi/authApi.ts index 039bafc..5ea88b8 100644 --- a/src/features/auth/api/config/authApi/authApi.ts +++ b/src/features/auth/api/config/authApi/authApi.ts @@ -1,44 +1,34 @@ -import { api as baseApi } from "shared/config"; +import { api as baseApi, useTokenStore } from "shared/api"; -type TokenGetter = () => string | null | undefined; +export const authHttpClient = baseApi.extend({ + hooks: { + beforeRequest: [ + (request) => { + const token = useTokenStore.getState().accessToken; -class HttpClient { - private getToken: TokenGetter = () => null; + if (token) { + request.headers.set("Authorization", `Bearer ${token}`); + } - setTokenGetter(fn: TokenGetter) { - this.getToken = fn; - } - - // Extend base API with authentication hooks - private instance = baseApi.extend({ - hooks: { - beforeRequest: [ - (request) => { - const token = this.getToken(); - - if (token) { - request.headers.set("Authorization", `Bearer ${token}`); - } - - return request; - }, - ], - afterResponse: [ - async (request, options, response) => { - // Refresh token logic + return request; + }, + ], + afterResponse: [ + async (request, options, response) => { + if (response.status !== 401) { return response; - }, - ], - }, - }); + } - get = (url: string, options?: Parameters[1]) => { - return this.instance.get(url, options); - }; + const { accessToken, refreshToken } = useTokenStore.getState(); + if (!accessToken) return response; - post = (url: string, options?: Parameters[1]) => { - return this.instance.post(url, options); - }; -} - -export const authHttpClient = new HttpClient(); + try { + await refreshToken?.(); + return authHttpClient(request); // beforeRequest picks up new token automatically + } catch { + return response; + } + }, + ], + }, +}); diff --git a/src/features/auth/model/stores/authStore/authStore.spec.ts b/src/features/auth/model/stores/authStore/authStore.spec.ts index 314c26b..1398b53 100644 --- a/src/features/auth/model/stores/authStore/authStore.spec.ts +++ b/src/features/auth/model/stores/authStore/authStore.spec.ts @@ -10,7 +10,6 @@ import { MOCK_EMAIL, MOCK_NEW_EMAIL, MOCK_PASSWORD, - MOCK_TOKEN, } from "../../../api/calls/mocks"; const server = setupServer(loginMock, registerMock, logoutMock, refreshMock); @@ -26,7 +25,7 @@ describe("authStore", () => { describe("reset", () => { it("should reset the store to default state", () => { useAuthStore.getState().reset(); - expect(useAuthStore.getState()).toMatchObject({ ...defaultStoreState }); + expect(useAuthStore.getState()).toMatchObject(defaultStoreState); }); }); @@ -36,9 +35,8 @@ describe("authStore", () => { .getState() .login({ email: MOCK_EMAIL, password: MOCK_PASSWORD }); - const { accessToken, user, status, error } = useAuthStore.getState(); + const { user, status, error } = useAuthStore.getState(); - expect(accessToken).toBeDefined(); expect(user).toBeDefined(); expect(status).toBe("authenticated"); expect(error).toBeNull(); @@ -62,9 +60,8 @@ describe("authStore", () => { .getState() .register({ email: MOCK_NEW_EMAIL, password: MOCK_PASSWORD }); - const { accessToken, user, status, error } = useAuthStore.getState(); + const { user, status, error } = useAuthStore.getState(); - expect(accessToken).toBeDefined(); expect(user).toBeDefined(); expect(status).toBe("authenticated"); expect(error).toBeNull(); @@ -86,38 +83,11 @@ describe("authStore", () => { it("should clear access token, user data, and update status after logout", async () => { await useAuthStore.getState().logout(); - const { accessToken, user, status, error } = useAuthStore.getState(); + const { user, status, error } = useAuthStore.getState(); - expect(accessToken).toBeUndefined(); expect(user).toBeUndefined(); expect(status).toBe("unauthenticated"); expect(error).toBeNull(); }); }); - - describe("refresh", () => { - it("should update access token and user data after successful refresh", async () => { - useAuthStore.setState({ accessToken: MOCK_TOKEN }); - - await useAuthStore.getState().refresh(); - - const { accessToken, user, status, error } = useAuthStore.getState(); - - expect(accessToken).toBeDefined(); - expect(user).toBeDefined(); - expect(status).toBe("authenticated"); - expect(error).toBeNull(); - }); - - it("should set error and update status if refresh fails", async () => { - useAuthStore.setState({ accessToken: "old_token" }); - - await useAuthStore.getState().refresh(); - - const { status, error } = useAuthStore.getState(); - - expect(status).toBe("unauthenticated"); - expect(error).toBeDefined(); - }); - }); }); diff --git a/src/features/auth/model/stores/authStore/authStore.ts b/src/features/auth/model/stores/authStore/authStore.ts index 9a4e864..6b6d36f 100644 --- a/src/features/auth/model/stores/authStore/authStore.ts +++ b/src/features/auth/model/stores/authStore/authStore.ts @@ -1,20 +1,18 @@ import { create } from "zustand"; import type { AuthStore, AuthStoreState } from "../../types/store"; -import { login, logout, refresh, register } from "../../../api"; +import { login, logout, register } from "../../../api"; import { callApi } from "shared/utils"; -import { UNEXPECTED_ERROR_MESSAGE } from "shared/config"; -import { authHttpClient } from "../../../api/config/authApi/authApi"; +import { UNEXPECTED_ERROR_MESSAGE } from "shared/api"; export const defaultStoreState: Readonly = { user: undefined, status: "idle", - accessToken: undefined, error: null, }; export const useAuthStore = create()((set) => ({ ...defaultStoreState, - reset: () => set({ ...defaultStoreState }), + reset: () => set(defaultStoreState), login: async (loginData) => { set({ status: "loading" }); try { @@ -28,9 +26,10 @@ export const useAuthStore = create()((set) => ({ set({ status: "authenticated", user: responseData?.user, - accessToken: responseData?.accessToken, error: null, }); + + // useTokenStore.setState({ accessToken: responseData?.accessToken }); } catch (err) { console.error(err); set({ @@ -53,9 +52,10 @@ export const useAuthStore = create()((set) => ({ set({ status: "authenticated", user: responseData?.user, - accessToken: responseData?.accessToken, error: null, }); + + // useTokenStore.setState({ accessToken: responseData?.accessToken }); } catch (err) { console.error(err); set({ @@ -77,38 +77,13 @@ export const useAuthStore = create()((set) => ({ set({ status: "unauthenticated", user: undefined, - accessToken: undefined, error: null, }); + + // useTokenStore.setState({ accessToken: null }); } catch (err) { console.error(err); set({ error: new Error(UNEXPECTED_ERROR_MESSAGE) }); } }, - refresh: async () => { - set({ status: "loading" }); - try { - const [responseData, refreshError] = await callApi(() => refresh()); - - if (refreshError) { - set({ status: "unauthenticated", error: refreshError }); - return; - } - - set({ - status: "authenticated", - user: responseData?.user, - accessToken: responseData?.accessToken, - error: null, - }); - } catch (err) { - console.error(err); - set({ - status: "unauthenticated", - error: new Error(UNEXPECTED_ERROR_MESSAGE), - }); - } - }, })); - -authHttpClient.setTokenGetter(() => useAuthStore.getState().accessToken); diff --git a/src/features/auth/model/types/store.ts b/src/features/auth/model/types/store.ts index dc05b0e..e6e356a 100644 --- a/src/features/auth/model/types/store.ts +++ b/src/features/auth/model/types/store.ts @@ -11,10 +11,6 @@ export interface AuthStoreState { * Authentication status */ status: AuthStatus; - /** - * Authentication token - */ - accessToken?: string; /** * Error data */ @@ -25,14 +21,12 @@ export type ResetAction = () => void; export type LoginAction = (data: AuthData) => Promise; export type RegisterAction = (data: AuthData) => Promise; export type LogoutAction = () => Promise; -export type RefreshAction = () => Promise; export interface AuthStoreActions { reset: ResetAction; login: LoginAction; register: RegisterAction; logout: LogoutAction; - refresh: RefreshAction; } export type AuthStore = AuthStoreState & AuthStoreActions; diff --git a/src/shared/api/calls/index.ts b/src/shared/api/calls/index.ts new file mode 100644 index 0000000..81b07ca --- /dev/null +++ b/src/shared/api/calls/index.ts @@ -0,0 +1,2 @@ +export * from "./refresh/refresh"; +export * from "./refresh/refresh.mock"; diff --git a/src/features/auth/api/calls/refresh/index.ts b/src/shared/api/calls/refresh/index.ts similarity index 100% rename from src/features/auth/api/calls/refresh/index.ts rename to src/shared/api/calls/refresh/index.ts diff --git a/src/shared/api/calls/refresh/refresh.mock.ts b/src/shared/api/calls/refresh/refresh.mock.ts new file mode 100644 index 0000000..2bddace --- /dev/null +++ b/src/shared/api/calls/refresh/refresh.mock.ts @@ -0,0 +1,26 @@ +import { http, HttpResponse } from "msw"; +import { REFRESH_API_ROUTE } from "./refresh"; +import { BASE_URL, MOCK_FRESH_TOKEN, MOCK_TOKEN } from "../../model"; + +const REFRESH_URL = `${BASE_URL}/${REFRESH_API_ROUTE}`; +const MOCK_BEARER_TOKEN = `Bearer ${MOCK_TOKEN}`; + +/** + * Msw interceptor. Mocks the refresh endpoint response. + * Cookie validation is a server concern — always returns a fresh token. + * Use server.use() to override with a 401 in tests that need a failure case. + */ +export const refreshMock = http.post(REFRESH_URL, async ({ request }) => { + if (request.headers.get("Authorization") === MOCK_BEARER_TOKEN) { + return HttpResponse.json({ + accessToken: MOCK_FRESH_TOKEN, + }); + } + + return HttpResponse.json( + { + error: "Unauthorized", + }, + { status: 401 }, + ); +}); diff --git a/src/features/auth/api/calls/refresh/refresh.spec.ts b/src/shared/api/calls/refresh/refresh.spec.ts similarity index 63% rename from src/features/auth/api/calls/refresh/refresh.spec.ts rename to src/shared/api/calls/refresh/refresh.spec.ts index f979c86..dfb84cb 100644 --- a/src/features/auth/api/calls/refresh/refresh.spec.ts +++ b/src/shared/api/calls/refresh/refresh.spec.ts @@ -1,8 +1,8 @@ import { setupServer } from "msw/node"; -import { useAuthStore } from "../../../model"; +import { useTokenStore } from "../../model/stores/tokenStore"; import { refresh } from "./refresh"; import { refreshMock } from "./refresh.mock"; -import { MOCK_EXISTING_USER, MOCK_FRESH_TOKEN, MOCK_TOKEN } from "../mocks"; +import { MOCK_FRESH_TOKEN, MOCK_TOKEN } from "../../model/const"; const server = setupServer(refreshMock); @@ -10,24 +10,21 @@ describe("refresh", () => { beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => { server.resetHandlers(); - useAuthStore.setState({ accessToken: undefined }); + useTokenStore.setState({ accessToken: undefined }); }); afterAll(() => server.close()); describe("happy path", () => { it("returns a fresh access token and user when session is valid", async () => { - useAuthStore.setState({ accessToken: MOCK_TOKEN }); - - const result = await refresh(); + const result = await refresh(MOCK_TOKEN); expect(result.accessToken).toBe(MOCK_FRESH_TOKEN); - expect(result.user).toEqual(MOCK_EXISTING_USER); }); }); describe("error cases", () => { it("throws when session is expired or missing", async () => { - await expect(refresh()).rejects.toThrow(); + await expect(refresh("expired_token")).rejects.toThrow(); }); }); }); diff --git a/src/shared/api/calls/refresh/refresh.ts b/src/shared/api/calls/refresh/refresh.ts new file mode 100644 index 0000000..cc615a1 --- /dev/null +++ b/src/shared/api/calls/refresh/refresh.ts @@ -0,0 +1,10 @@ +import { api } from "../../config/api"; +import type { RefreshTokenResponse } from "../../model/types"; + +export const REFRESH_API_ROUTE = "auth/refresh"; + +export function refresh(token: string) { + return api + .post(REFRESH_API_ROUTE, { headers: { Authorization: `Bearer ${token}` } }) + .json(); +} diff --git a/src/shared/api/config/api.ts b/src/shared/api/config/api.ts new file mode 100644 index 0000000..a0a7050 --- /dev/null +++ b/src/shared/api/config/api.ts @@ -0,0 +1,20 @@ +import ky from "ky"; +import { BASE_URL } from "../model/const"; +import { useTokenStore } from "../model"; + +export const api = ky.create({ + prefixUrl: BASE_URL, + hooks: { + beforeRequest: [ + (request) => { + const token = useTokenStore.getState().accessToken; + + if (token) { + request.headers.set("Authorization", `Bearer ${token}`); + } + + return request; + }, + ], + }, +}); diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts new file mode 100644 index 0000000..c6aaa77 --- /dev/null +++ b/src/shared/api/index.ts @@ -0,0 +1,2 @@ +export * from "./model"; +export { api } from "./config/api"; diff --git a/src/shared/config/api/constants.ts b/src/shared/api/model/const/index.ts similarity index 60% rename from src/shared/config/api/constants.ts rename to src/shared/api/model/const/index.ts index 54586f4..bc30b36 100644 --- a/src/shared/config/api/constants.ts +++ b/src/shared/api/model/const/index.ts @@ -2,3 +2,5 @@ export const BASE_URL = import.meta.env.VITE_API_BASE_URL || "https://localhost:3001"; export const UNEXPECTED_ERROR_MESSAGE = "An unexpected error occured"; +export const MOCK_TOKEN = "mock.access.token"; +export const MOCK_FRESH_TOKEN = "mock.fresh.access.token"; diff --git a/src/shared/api/model/index.ts b/src/shared/api/model/index.ts new file mode 100644 index 0000000..2282dc9 --- /dev/null +++ b/src/shared/api/model/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; +export * from "./stores/tokenStore"; +export * from "./const"; diff --git a/src/shared/api/model/stores/tokenStore.spec.ts b/src/shared/api/model/stores/tokenStore.spec.ts new file mode 100644 index 0000000..fd5defb --- /dev/null +++ b/src/shared/api/model/stores/tokenStore.spec.ts @@ -0,0 +1,45 @@ +import { setupServer } from "msw/node"; +import { MOCK_TOKEN } from "../const"; +import { defaultStoreState, useTokenStore } from "./tokenStore"; +import { refreshMock } from "shared/api/calls"; + +const server = setupServer(refreshMock); + +describe("tokenStore", () => { + beforeAll(() => server.listen({ onUnhandledRequest: "error" })); + afterEach(() => { + useTokenStore.getState().reset(); + server.resetHandlers(); + }); + afterAll(() => server.close()); + + describe("reset", () => { + it("should reset the store to default state", () => { + useTokenStore.getState().reset(); + expect(useTokenStore.getState()).toMatchObject(defaultStoreState); + }); + }); + + describe("refreshToken", () => { + it("should update access token after successful refresh", async () => { + useTokenStore.setState({ accessToken: MOCK_TOKEN }); + + await useTokenStore.getState().refreshToken(); + + const { accessToken, error } = useTokenStore.getState(); + + expect(accessToken).toBeDefined(); + expect(error).toBeNull(); + }); + + it("should set error if refresh fails", async () => { + useTokenStore.setState({ accessToken: "old_token" }); + + await useTokenStore.getState().refreshToken(); + + const { error } = useTokenStore.getState(); + + expect(error).toBeDefined(); + }); + }); +}); diff --git a/src/shared/api/model/stores/tokenStore.ts b/src/shared/api/model/stores/tokenStore.ts new file mode 100644 index 0000000..e042a71 --- /dev/null +++ b/src/shared/api/model/stores/tokenStore.ts @@ -0,0 +1,39 @@ +import { create } from "zustand"; +import type { TokenStore, TokenStoreState } from "../types"; +import { callApi } from "shared/utils"; +import { refresh } from "../../calls/refresh"; +import { UNEXPECTED_ERROR_MESSAGE } from "../const"; + +export const defaultStoreState: TokenStoreState = { + accessToken: null, + error: null, +}; + +export const useTokenStore = create()((set, get) => ({ + ...defaultStoreState, + reset: () => set(defaultStoreState), + refreshToken: async () => { + try { + const currentToken = get().accessToken; + if (!currentToken) { + return; + } + + const [refreshResponse, refreshError] = await callApi(() => + refresh(currentToken), + ); + + if (refreshError) { + set({ error: refreshError }); + return; + } + + set({ accessToken: refreshResponse?.accessToken }); + } catch (error) { + console.error(error); + set({ + error: new Error(UNEXPECTED_ERROR_MESSAGE), + }); + } + }, +})); diff --git a/src/shared/api/model/types/index.ts b/src/shared/api/model/types/index.ts new file mode 100644 index 0000000..2a051c9 --- /dev/null +++ b/src/shared/api/model/types/index.ts @@ -0,0 +1,17 @@ +import type { ApiError } from "shared/utils"; + +export interface TokenStoreState { + accessToken: string | null; + error: ApiError | Error | null; +} + +export interface TokenStoreActions { + reset: () => void; + refreshToken: () => Promise; +} + +export type TokenStore = TokenStoreState & TokenStoreActions; + +export interface RefreshTokenResponse { + accessToken: string; +} diff --git a/src/shared/config/api/httpClient.ts b/src/shared/config/api/httpClient.ts deleted file mode 100644 index 84a67a4..0000000 --- a/src/shared/config/api/httpClient.ts +++ /dev/null @@ -1,6 +0,0 @@ -import ky from "ky"; -import { BASE_URL } from "./constants"; - -export const api = ky.create({ - prefixUrl: BASE_URL, -}); diff --git a/src/shared/config/index.ts b/src/shared/config/index.ts deleted file mode 100644 index e5797aa..0000000 --- a/src/shared/config/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./api/constants"; -export * from "./api/httpClient";