From f297b4986c108f44666ef2f563b1b6da3af3e459 Mon Sep 17 00:00:00 2001 From: Stepan Pilipenko Date: Wed, 5 Nov 2025 22:55:50 +0300 Subject: [PATCH] slice --- backend/server/views.py | 38 +++--- backend/tests/server_api_test.py | 26 +++- frontend/shop/src/components/product-card.tsx | 4 +- .../shop/src/components/product-detail.tsx | 4 +- frontend/shop/src/components/product-list.tsx | 8 +- frontend/shop/src/layout/layout.tsx | 6 +- frontend/shop/src/network.ts | 36 ++++-- frontend/shop/src/pages/login.tsx | 22 +++- frontend/shop/src/pages/shop.tsx | 5 +- frontend/shop/src/storage/auth-slice.ts | 115 ++++++++++++++++++ frontend/shop/src/storage/hooks.ts | 9 ++ frontend/shop/src/storage/index.ts | 2 + frontend/shop/src/storage/shop-slice.ts | 50 +++++--- frontend/shop/src/storage/user-slice.ts | 77 ++++++++++++ frontend/shop/src/store.ts | 14 ++- frontend/shop/src/types/index.ts | 1 + frontend/shop/src/types/product.ts | 2 +- frontend/shop/src/types/user.ts | 9 ++ 18 files changed, 348 insertions(+), 80 deletions(-) create mode 100644 frontend/shop/src/storage/auth-slice.ts create mode 100644 frontend/shop/src/storage/user-slice.ts create mode 100644 frontend/shop/src/types/user.ts diff --git a/backend/server/views.py b/backend/server/views.py index 48a4125..cde3196 100644 --- a/backend/server/views.py +++ b/backend/server/views.py @@ -23,30 +23,32 @@ async def user(request): try: user1 = dict() if request.method == 'POST': - body: dict = json.loads(request.body) - if body["register"]: - token = api.registration(body["register"]["nickname"], - body["register"]["password"], - body["register"]["email"]) - elif body["login"]: - token = api.login(body["login"]["email"], - body["login"]["password"]) - elif body["unregister"]: - token = format_token(request.headers.get("Authorization")) - api.unregister(token) - elif body["logout"]: - token = format_token(request.headers.get("Authorization")) - api.logout(token) - elif body["add_money"]: - token = format_token(request.headers.get("Authorization")) - api.add_money(token, body["add_money"]["money"]) + if request.body: + body: dict = json.loads(request.body) + + if body["register"]: + token = api.registration(body["register"]["nickname"], + body["register"]["password"], + body["register"]["email"]) + elif body["login"]: + token = api.login(body["login"]["email"], + body["login"]["password"]) + elif body["unregister"]: + token = format_token(request.headers.get("Authorization")) + api.unregister(token) + elif body["logout"]: + token = format_token(request.headers.get("Authorization")) + api.logout(token) + elif body["add_money"]: + token = format_token(request.headers.get("Authorization")) + api.add_money(token, body["add_money"]["money"]) else: token = format_token(request.headers.get("Authorization")) user1 = api.get_user(token) - return JsonResponse({"success": list(user1)}, status=200) + return JsonResponse({"success": [user1]}, status=200) except Exception as error: return JsonResponse({"error": format(error)}, status=500) diff --git a/backend/tests/server_api_test.py b/backend/tests/server_api_test.py index 8760d5b..6267c49 100644 --- a/backend/tests/server_api_test.py +++ b/backend/tests/server_api_test.py @@ -161,7 +161,28 @@ class TestGameShopAPI(unittest.TestCase): error_msg = get_error_message(basket_resp) self.fail(f"Токен от /login/ не работает: {error_msg}") - def test_8_logout_success(self): + def test_8_user_success(self): + """POST /user/ с валидным токеном → возвращает данные пользователя""" + headers = {"Authorization": "Bearer " + self.valid_token} + response = requests.post(f"{BASE_URL}/user/", headers=headers) + if response.status_code != 200: + error_msg = get_error_message(response) + self.fail(f"POST /user/ failed ({response.status_code}): {error_msg}") + + data = response.json() + self.assertIn("success", data, f"Ответ не содержит 'success'. Получено: {data}") + + user_data = data["success"][0] + + required_fields = {"id", "nickname", "email", "token", "money", "histories_id"} + missing = required_fields - set(user_data.keys()) + self.assertFalse(missing, f"В данных пользователя отсутствуют поля: {missing}") + self.assertEqual(user_data["nickname"], self.test_nickname) + self.assertEqual(user_data["email"], self.test_email) + self.assertIsInstance(float(user_data["money"]), float) + self.assertIsInstance(user_data["histories_id"], list) + + def test_9_logout_success(self): """POST /logout/ с валидным токеном — успешный выход""" headers = {"Authorization": "Bearer " + self.valid_token} response = requests.post(f"{BASE_URL}/logout/", headers=headers) @@ -173,10 +194,9 @@ class TestGameShopAPI(unittest.TestCase): self.assertIn("success", data, f"Ответ не содержит 'OK'. Получено: {data}") self.assertEqual(data["success"], self.valid_token, "В ответе должен быть тот же токен") - def test_9_all_scenarios_completed(self): + def test_10_all_scenarios_completed(self): """Финальная проверка: все тесты пройдены""" pass - if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/frontend/shop/src/components/product-card.tsx b/frontend/shop/src/components/product-card.tsx index c477f8a..a33adf7 100644 --- a/frontend/shop/src/components/product-card.tsx +++ b/frontend/shop/src/components/product-card.tsx @@ -12,14 +12,14 @@ import { import { type SyntheticEvent, useCallback } from 'react' import { Link } from 'react-router-dom' import { CartButton } from './cart-button' -import { useAppDispatch, useAppSelector, selectProduct } from '../storage' +import { useAppDispatch, useAppSelector, shop } from '../storage' export type ProductCardProps = { productId: number } export const ProductCard = ({ productId }: ProductCardProps) => { - const product = useAppSelector(selectProduct(productId)) + const product = useAppSelector(shop.selector.product(productId)) const { id, name, count, reserved, picture_url, description, type, cost } = product || { id: productId, name: '', diff --git a/frontend/shop/src/components/product-detail.tsx b/frontend/shop/src/components/product-detail.tsx index b60a153..8e8979b 100644 --- a/frontend/shop/src/components/product-detail.tsx +++ b/frontend/shop/src/components/product-detail.tsx @@ -10,14 +10,14 @@ import { } from '@mui/material' import { useCallback } from 'react' import { CartButton } from './cart-button' -import { useAppSelector, selectProduct } from '../storage' +import { useAppSelector, shop } from '../storage' export type ProductDetailProps = { productId: number } export const ProductDetail = ({ productId }: ProductDetailProps) => { - const product = useAppSelector(selectProduct(productId)) + const product = useAppSelector(shop.selector.product(productId)) const { id, name, count, reserved, picture_url, description, type, cost } = product || { id: productId, name: '', diff --git a/frontend/shop/src/components/product-list.tsx b/frontend/shop/src/components/product-list.tsx index 02b01d7..bdd9ee4 100644 --- a/frontend/shop/src/components/product-list.tsx +++ b/frontend/shop/src/components/product-list.tsx @@ -1,19 +1,19 @@ import { Grid } from '@mui/material' import { useCallback, useState, type JSX } from 'react' import { ProductCard } from './product-card' -import { type Product } from '../types' +import { type ProductType } from '../types' import { LoadMore } from './load-more' -import { useAppSelector, selectProducts } from '../storage' +import { useAppSelector, shop } from '../storage' export type ProductListProps = { - products: Product[] + products: ProductType[] loading?: boolean } export const ProductList = ({ products, loading = false }: ProductListProps): JSX.Element => { const [page, setPage] = useState(1) - const productsLength = useAppSelector(selectProducts).length + const productsLength = useAppSelector(shop.selector.products).length const isEndOfList = products && products.length >= productsLength const loadMorePosts = useCallback(() => { diff --git a/frontend/shop/src/layout/layout.tsx b/frontend/shop/src/layout/layout.tsx index b911e70..7f04d62 100644 --- a/frontend/shop/src/layout/layout.tsx +++ b/frontend/shop/src/layout/layout.tsx @@ -5,15 +5,15 @@ import { useEffect, type JSX } from 'react' import { Header } from './header' import { Footer } from './footer' import { Body } from './body' -import { fetchProducts, selectShopLoading, useAppDispatch, useAppSelector } from '../storage' +import { shop, useAppDispatch, useAppSelector } from '../storage' export const Layout = (): JSX.Element => { const dispatch = useAppDispatch() - const loading = useAppSelector(selectShopLoading) + const loading = useAppSelector(shop.selector.loading) useEffect(() => { - dispatch(fetchProducts()) + dispatch(shop.fetch.products()) }, []) return ( diff --git a/frontend/shop/src/network.ts b/frontend/shop/src/network.ts index 84e45a1..b94b029 100644 --- a/frontend/shop/src/network.ts +++ b/frontend/shop/src/network.ts @@ -16,33 +16,44 @@ const DEFAULT_HEADERS: HeaderType = { } export interface NetworkApi { - setAccessToken: (token: string) => void - onResponse: (res: Response) => Promise + get token(): string + set token(value: string) request: (endpoint: string, options?: RequestInit) => Promise } class Network implements NetworkApi { - private baseUrl: string - private headers: HeaderType + private _baseUrl: string + private _headers: HeaderType + private _token: string constructor() { if (import.meta.env.DEV) { - this.baseUrl = DEV_URL + this._baseUrl = DEV_URL } else { - this.baseUrl = PROD_URL + this._baseUrl = PROD_URL } - this.headers = DEFAULT_HEADERS + this._headers = DEFAULT_HEADERS + this._token = '' } private getStringURL(endpoint: string): string { - return `${this.baseUrl}${endpoint.replace(/^\//, '')}` + return `${this._baseUrl}/${endpoint.replace(/^\//, '')}` } - setAccessToken(token: string): void { - this.headers.authorization = token + get token(): string { + return this._token } - async onResponse(res: Response): Promise { + set token(value: string) { + if (value) { + this._headers.authorization = `Bearer ${value}` + } else { + this._headers.authorization = '' + } + this._token = value + } + + private async onResponse(res: Response): Promise { return res.ok ? res.json() : res.json().then(data => Promise.reject(data)) } @@ -50,9 +61,8 @@ class Network implements NetworkApi { const res = await fetch(this.getStringURL(endpoint), { method: 'GET', ...options, - headers: { ...this.headers, ...options?.headers }, + headers: { ...this._headers, ...options?.headers }, }) - return await this.onResponse(res) } } diff --git a/frontend/shop/src/pages/login.tsx b/frontend/shop/src/pages/login.tsx index 30d3cbd..cd079f2 100644 --- a/frontend/shop/src/pages/login.tsx +++ b/frontend/shop/src/pages/login.tsx @@ -1,4 +1,4 @@ -import type { JSX } from 'react' +import { useEffect, type JSX } from 'react' import { Avatar, Box, Container, TextField, Typography, Button, Link } from '@mui/material' import KeyIcon from '@mui/icons-material/Key' import { Controller, type SubmitHandler, useForm } from 'react-hook-form' @@ -6,7 +6,7 @@ import { yupResolver } from '@hookform/resolvers/yup' import { toast } from 'react-toastify' import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom' import * as yup from 'yup' -import { useAppDispatch } from '../storage' +import { auth, useAppDispatch, useAppSelector } from '../storage' import type { ErrorType } from '../types' export interface LoginFormValues { @@ -21,10 +21,23 @@ export const loginFormSchema = yup.object({ export const LoginPage = (): JSX.Element => { const dispatch = useAppDispatch() + const token = useAppSelector(auth.selector.accessToken) + const error = useAppSelector(auth.selector.error) const { state } = useLocation() const navigate = useNavigate() + useEffect(() => { + if (token && !error) { + toast.success('Вы успешно вошли в систему!') + // переходит туда откуда выпали на логин + navigate(state.from) + } + if (error) { + toast.error(error) + } + }, [token, error]) + // инициализируем react-hook-form const { // control понадобиться, чтобы подружить react-hook-form и компоненты из MUI @@ -44,10 +57,7 @@ export const LoginPage = (): JSX.Element => { const submitHandler: SubmitHandler = async values => { try { - // TODO: логинемся и запрашиваем все данные - toast.success('Вы успешно вошли в систему!') - // переходит туда откуда выпали на логин - navigate(state.from) + dispatch(auth.fetch.login({ email: values.email, password: values.password })) } catch (error: unknown) { // Если произошла ошибка, то выводим уведомление const errorText = (error as ErrorType).error diff --git a/frontend/shop/src/pages/shop.tsx b/frontend/shop/src/pages/shop.tsx index 4a8fc24..16b855d 100644 --- a/frontend/shop/src/pages/shop.tsx +++ b/frontend/shop/src/pages/shop.tsx @@ -1,11 +1,10 @@ import { memo } from 'react' import { Container } from '@mui/material' -import { useAppSelector } from '../storage' -import { selectProducts } from '../storage/shop-slice' +import { useAppSelector, shop } from '../storage' import { ProductList } from '../components' export const ShopPage = memo(() => { - const products = useAppSelector(selectProducts) + const products = useAppSelector(shop.selector.products) return ( diff --git a/frontend/shop/src/storage/auth-slice.ts b/frontend/shop/src/storage/auth-slice.ts new file mode 100644 index 0000000..274bf5d --- /dev/null +++ b/frontend/shop/src/storage/auth-slice.ts @@ -0,0 +1,115 @@ +import { createSlice } from '@reduxjs/toolkit' +import type { PayloadAction } from '@reduxjs/toolkit' +import { createAppAsyncThunk } from './hooks' +import { networkApi, type ResponseData } from '../network' + +interface AuthState { + accessToken: string + loading: boolean + error?: string +} + +const initialState: AuthState = { + accessToken: '', + loading: false, + error: undefined, +} + +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + setAccessToken(state, action: PayloadAction) { + state.accessToken = action.payload.accessToken + }, + clearToken() { + return initialState + }, + }, + extraReducers: builder => { + builder + .addCase(fetchLogin.pending, state => { + state.loading = true + state.error = undefined + }) + .addCase(fetchLogin.fulfilled, (state, action) => { + state.accessToken = String(action.payload.success?.[0]) || '' + state.loading = false + state.error = undefined + networkApi.token = state.accessToken + }) + .addCase(fetchLogin.rejected, (state, action) => { + state.loading = false + if (action.payload) { + state.error = (action.payload as ResponseData).error + } else { + state.error = action.error.message + } + }) + .addCase(fetchLogout.pending, state => { + state.loading = true + state.error = undefined + }) + .addCase(fetchLogout.fulfilled, state => { + state.accessToken = '' + state.loading = false + state.error = undefined + networkApi.token = '' + }) + .addCase(fetchLogout.rejected, (state, action) => { + state.loading = false + if (action.payload) { + state.error = (action.payload as ResponseData).error + } else { + state.error = action.error.message + } + }) + }, + selectors: { + accessToken: (state: AuthState) => state.accessToken, + loading: (state: AuthState) => state.loading, + error: (state: AuthState) => state.error, + }, +}) + +type LoginProps = { + email: string + password: string +} + +export const fetchLogin = createAppAsyncThunk( + `${authSlice.name}/fetchLogin`, + async (props, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { + try { + const data = await networkApi.request('/login/', { + method: 'POST', + body: JSON.stringify(props), + }) + return fulfillWithValue(data) + } catch (error) { + return rejectWithValue(error) + } + } +) + +export const fetchLogout = createAppAsyncThunk( + `${authSlice.name}/fetchLogout`, + async (_, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { + try { + const data = await networkApi.request('/logout/') + return fulfillWithValue(data) + } catch (error) { + return rejectWithValue(error) + } + } +) + +class Auth { + selector = { ...authSlice.selectors } + action = authSlice.actions + reducer = authSlice.reducer + name = authSlice.name + fetch = { login: fetchLogin, logout: fetchLogout } +} + +export const auth = new Auth() diff --git a/frontend/shop/src/storage/hooks.ts b/frontend/shop/src/storage/hooks.ts index 5851603..3399262 100644 --- a/frontend/shop/src/storage/hooks.ts +++ b/frontend/shop/src/storage/hooks.ts @@ -1,5 +1,14 @@ import { useDispatch, useSelector } from 'react-redux' import type { AppDispatch, RootState } from '../store' +import { createAsyncThunk } from '@reduxjs/toolkit' +import type { NetworkApi } from '../network' export const useAppDispatch = useDispatch.withTypes() export const useAppSelector = useSelector.withTypes() + +export const createAppAsyncThunk = createAsyncThunk.withTypes<{ + state: RootState + getState: () => RootState + dispatch: AppDispatch + extra: NetworkApi +}>() diff --git a/frontend/shop/src/storage/index.ts b/frontend/shop/src/storage/index.ts index 5e87f76..0a07543 100644 --- a/frontend/shop/src/storage/index.ts +++ b/frontend/shop/src/storage/index.ts @@ -1,2 +1,4 @@ export * from './shop-slice' +export * from './auth-slice' +export * from './user-slice' export * from './hooks' diff --git a/frontend/shop/src/storage/shop-slice.ts b/frontend/shop/src/storage/shop-slice.ts index 418cc1b..7a7e915 100644 --- a/frontend/shop/src/storage/shop-slice.ts +++ b/frontend/shop/src/storage/shop-slice.ts @@ -1,7 +1,7 @@ -import { createAsyncThunk, createSlice, type PayloadAction, createSelector } from '@reduxjs/toolkit' -import type { RootState, ThunkApi } from '../store' -import type { Product } from '../types' +import { createSlice, type PayloadAction, createSelector } from '@reduxjs/toolkit' +import type { ProductType } from '../types' import type { ResponseData } from '../network' +import { createAppAsyncThunk } from './hooks' interface AddBasketAction { id: number @@ -9,7 +9,7 @@ interface AddBasketAction { } interface ShopState { - products: Product[] + products: ProductType[] loading: boolean error?: string } @@ -24,10 +24,13 @@ export const shopSlice = createSlice({ name: 'shop', initialState, reducers: { - // TODO: дописать action.payload.id addToBasket: (state, action: PayloadAction) => { - state.products[0].count -= action.payload.count - state.products[0].reserved += action.payload.count + for (const product of state.products) { + if (product.id === action.payload.id) { + product.count -= action.payload.count + product.reserved += action.payload.count + } + } }, }, extraReducers: builder => { @@ -37,7 +40,7 @@ export const shopSlice = createSlice({ state.error = undefined }) .addCase(fetchProducts.fulfilled, (state, action) => { - state.products = action.payload.success as Product[] + state.products = action.payload.success as ProductType[] state.loading = false }) .addCase(fetchProducts.rejected, (state, action) => { @@ -49,23 +52,20 @@ export const shopSlice = createSlice({ } }) }, + selectors: { + products: (state: ShopState) => state.products, + loading: (state: ShopState) => state.loading, + error: (state: ShopState) => state.error, + }, }) -export const { addToBasket } = shopSlice.actions - -export const selectShopLoading = (state: RootState) => state.shop.loading -export const selectShopError = (state: RootState) => state.shop.error -export const selectProducts = (state: RootState) => state.shop.products - -export const selectProduct = (productId: number) => - createSelector([selectProducts], products => { +const selectProduct = (productId: number) => + createSelector([shopSlice.selectors.products], products => { return products.find(product => product.id === productId) }) -export const shopReducer = shopSlice.reducer - -export const fetchProducts = createAsyncThunk( - 'products/fetchProducts', +export const fetchProducts = createAppAsyncThunk( + `${shopSlice.name}/fetchProducts`, async (_, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { try { const data = await networkApi.request('/shop/') @@ -75,3 +75,13 @@ export const fetchProducts = createAsyncThunk( } } ) + +class Shop { + selector = { ...shopSlice.selectors, product: selectProduct } + action = shopSlice.actions + reducer = shopSlice.reducer + name = shopSlice.name + fetch = { products: fetchProducts } +} + +export const shop = new Shop() diff --git a/frontend/shop/src/storage/user-slice.ts b/frontend/shop/src/storage/user-slice.ts new file mode 100644 index 0000000..8f5ad93 --- /dev/null +++ b/frontend/shop/src/storage/user-slice.ts @@ -0,0 +1,77 @@ +import { type PayloadAction, createSlice } from '@reduxjs/toolkit' +import { type UserType } from '../types' +import { createAppAsyncThunk } from './hooks' +import type { ResponseData } from '../network' + +type UserState = { + user?: UserType + loading: boolean + error?: string +} + +const initialState: UserState = { + user: undefined, + loading: false, + error: undefined, +} + +export const userSliceName = 'user' + +const userSlice = createSlice({ + name: userSliceName, + initialState, + reducers: { + setUser: (state, action: PayloadAction) => { + state.user = action.payload + }, + clearUser() { + return initialState + }, + }, + extraReducers: builder => { + builder + .addCase(fetchUser.pending, state => { + state.loading = true + state.error = undefined + }) + .addCase(fetchUser.fulfilled, (state, action) => { + state.user = action.payload.success?.[0] as UserType + state.loading = false + }) + .addCase(fetchUser.rejected, (state, action) => { + state.loading = false + if (action.payload) { + state.error = (action.payload as ResponseData).error + } else { + state.error = action.error.message + } + }) + }, + selectors: { + user: (state: UserState) => state.user, + loading: (state: UserState) => state.loading, + error: (state: UserState) => state.error, + }, +}) + +export const fetchUser = createAppAsyncThunk( + `${userSliceName}/fetchUser`, + async (_, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { + try { + const data = await networkApi.request('/user/') + return fulfillWithValue(data) + } catch (error) { + return rejectWithValue(error) + } + } +) + +class User { + selector = { ...userSlice.selectors } + action = userSlice.actions + reducer = userSlice.reducer + name = userSlice.name + fetch = { user: fetchUser } +} + +export const user = new User() diff --git a/frontend/shop/src/store.ts b/frontend/shop/src/store.ts index be5d965..35fee86 100644 --- a/frontend/shop/src/store.ts +++ b/frontend/shop/src/store.ts @@ -1,11 +1,15 @@ -import { configureStore } from '@reduxjs/toolkit' +import { combineReducers, configureStore } from '@reduxjs/toolkit' import { networkApi, type NetworkApi } from './network' -import { shopReducer } from './storage' +import { shop, auth, user } from './storage' + +const reducer = combineReducers({ + [auth.name]: auth.reducer, + [shop.name]: shop.reducer, + [user.name]: user.reducer, +}) export const store = configureStore({ - reducer: { - shop: shopReducer, - }, + reducer, middleware: getDefaultMiddleware => getDefaultMiddleware({ thunk: { diff --git a/frontend/shop/src/types/index.ts b/frontend/shop/src/types/index.ts index a215cd8..7f1b33c 100644 --- a/frontend/shop/src/types/index.ts +++ b/frontend/shop/src/types/index.ts @@ -1,3 +1,4 @@ export * from './product' export * from './error' export * from './clean-component' +export * from './user' diff --git a/frontend/shop/src/types/product.ts b/frontend/shop/src/types/product.ts index c98a7f4..5e5f810 100644 --- a/frontend/shop/src/types/product.ts +++ b/frontend/shop/src/types/product.ts @@ -1,4 +1,4 @@ -export interface Product { +export type ProductType = { id: number name: string count: number diff --git a/frontend/shop/src/types/user.ts b/frontend/shop/src/types/user.ts new file mode 100644 index 0000000..0f78a76 --- /dev/null +++ b/frontend/shop/src/types/user.ts @@ -0,0 +1,9 @@ +export type UserType = { + id: number + nickname: string + email: string + token: string + token_expiry_date: Date + money: string + histories_id: number[] +}