diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 17799b72..c82c67d8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import NotFound from "pages/NotFound"; import PartDetails from "pages/PartDetails"; import Parts from "pages/Parts"; import RobotDetails from "pages/RobotDetails"; +import RobotForm from "pages/RobotForm"; import Robots from "pages/Robots"; import { Container } from "react-bootstrap"; import { BrowserRouter as Router, Route, Routes } from "react-router-dom"; @@ -35,6 +36,7 @@ const App = () => { } /> } /> } /> + } /> } /> diff --git a/frontend/src/hooks/api.tsx b/frontend/src/hooks/api.tsx new file mode 100644 index 00000000..510406c3 --- /dev/null +++ b/frontend/src/hooks/api.tsx @@ -0,0 +1,134 @@ +import axios from "axios"; + +export interface PurchaseLink { + url: string; + price: number; + name: string; +} + +export interface UsedBy { + name: string; + id: string; + stars: number; +} + +export interface Part { + name: string; + owner: string; + description: string; + images: Image[]; + part_id: string; + used_by: UsedBy[]; + purchase_links: PurchaseLink[]; +} + +export interface Bom { + id: string; + name: string; + quantity: number; + price: number; +} + +export interface Image { + caption: string; + url: string; +} + +export interface Robot { + robot_id: string; + name: string; + description: string; + owner: string; + bom: Bom[]; + images: Image[]; +} + +class api { + private api; + + constructor(baseURL: string | undefined) { + this.api = axios.create({ + baseURL, + headers: { + "Content-Type": "application/json", + }, + withCredentials: true, // Ensure credentials are sent + }); + } + public async getRobots(): Promise { + try { + const response = await this.api.get("/robots"); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + console.error("Error fetching robots:", error.response?.data); + throw new Error( + error.response?.data?.detail || "Error fetching robots", + ); + } else { + console.error("Unexpected error:", error); + throw new Error("Unexpected error"); + } + } + } + public async getRobotById(robotId: string | undefined): Promise { + try { + const response = await this.api.get(`/robots/${robotId}`); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + console.error("Error fetching robot:", error.response?.data); + throw new Error(error.response?.data?.detail || "Error fetching robot"); + } else { + console.error("Unexpected error:", error); + throw new Error("Unexpected error"); + } + } + } + public async getPartById(partId: string | undefined): Promise { + try { + const response = await this.api.get(`/parts/${partId}`); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + console.error("Error fetching robot:", error.response?.data); + throw new Error(error.response?.data?.detail || "Error fetching robot"); + } else { + console.error("Unexpected error:", error); + throw new Error("Unexpected error"); + } + } + } + public async addRobot(robot: Robot): Promise { + const s = robot.name; + try { + await this.api.post("/add/robot/", robot); + } catch (error) { + if (axios.isAxiosError(error)) { + console.error("Error adding robot:", error.response?.data); + throw new Error( + error.response?.data?.detail || "Error adding robot " + s, + ); + } else { + console.error("Unexpected error:", error); + throw new Error("Unexpected error"); + } + } + } + public async getParts(): Promise { + try { + const response = await this.api.get("/parts"); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + console.error("Error fetching parts:", error.response?.data); + throw new Error(error.response?.data?.detail || "Error fetching parts"); + } else { + console.error("Unexpected error:", error); + throw new Error("Unexpected error"); + } + } + } +} + +export default new api("http://127.0.0.1:8080/api"); diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 8e7bc91f..ebd47b67 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,17 +1,17 @@ -import { Card, Col, Row } from "react-bootstrap"; +import { Button, Card, Col, Row } from "react-bootstrap"; import { useNavigate } from "react-router-dom"; const Home = () => { const navigate = useNavigate(); return ( - - +
+

robolist

Buy and sell robots and robot parts

- + navigate(`/robots`)}> Robots @@ -19,7 +19,7 @@ const Home = () => { - + navigate(`/parts`)}> Parts @@ -28,7 +28,26 @@ const Home = () => { - + + + + + +
); }; diff --git a/frontend/src/pages/PartDetails.tsx b/frontend/src/pages/PartDetails.tsx index 5f81aaaa..0342ae5c 100644 --- a/frontend/src/pages/PartDetails.tsx +++ b/frontend/src/pages/PartDetails.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import api from "hooks/api"; +import { useEffect, useState } from "react"; import { Breadcrumb, Button, @@ -23,50 +24,51 @@ interface PartDetailsResponse { const PartDetails = () => { const { id } = useParams(); const [show, setShow] = useState(false); + const [part, setPart] = useState(null); const [imageIndex, setImageIndex] = useState(0); + const [error, setError] = useState(null); const handleClose = () => setShow(false); const handleShow = () => setShow(true); - // This is a placeholder before the backend is hooked up. + useEffect(() => { + const fetchPart = async () => { + try { + const partData = await api.getPartById(id); + setPart(partData); + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } else { + setError("An unexpected error occurred"); + } + } + }; + fetchPart(); + }, [id]); + + const navigate = useNavigate(); + + useEffect(() => { + if (error) { + navigate("/404"); // Redirect to a 404 page + } + }, [error, navigate]); + + if (!part) { + return

Loading

; + } + const response: PartDetailsResponse = { - name: "RMD X8", - owner: "MyActuator", - description: `The RMD X8 is a quasi-direct drive motor from MyActuator.`, - images: [ - { - url: "https://media.robolist.xyz/rmd_x8.png", - caption: "Actuator 1", - }, - { - url: "https://media.robolist.xyz/rmd_x8.png", - caption: "Actuator 2", - }, - { - url: "https://media.robolist.xyz/rmd_x8.png", - caption: "Actuator 3", - }, - ], - purchase_links: [ - { - name: "RobotShop", - url: "https://www.robotshop.com/products/myactuator-rmd-x8-v3-can-bus-16-helical-mc-x-500-o-brushless-servo-driver", - price: 389, - }, - ], - used_by: [ - { - name: "Stompy", - id: "1234", - stars: 5, - }, - ], + name: part.name, + owner: part.owner, + description: part.description, + images: part.images, + purchase_links: part.purchase_links, + used_by: part.used_by, }; - const { name, owner, description, images } = response; - const navigate = useNavigate(); - return ( <> diff --git a/frontend/src/pages/Parts.tsx b/frontend/src/pages/Parts.tsx index 030f9f3e..b9870e26 100644 --- a/frontend/src/pages/Parts.tsx +++ b/frontend/src/pages/Parts.tsx @@ -1,31 +1,81 @@ +import api from "hooks/api"; +import { useEffect, useState } from "react"; import { Breadcrumb, Card, Col, Row } from "react-bootstrap"; import { useNavigate } from "react-router-dom"; -interface PartsResponse { - robots: { - name: string; - owner: string; - description: string; - id: string; - photo?: string; - }[]; +interface Image { + caption: string; + url: string; } +interface PurchaseLink { + url: string; + price: number; + name: string; +} + +interface UsedBy { + name: string; + id: string; + stars: number; +} + +interface Part { + name: string; + owner: string; + description: string; + images: Image[]; + part_id: string; + used_by: UsedBy[]; + purchase_links: PurchaseLink[]; +} + +// interface PartsResponse { +// parts: Part[]; +// } + const Parts = () => { - const response: PartsResponse = { - robots: [ - { - name: "RMD X8", - owner: "MyActuator", - description: "6:1 reduction ratio motor", - id: "1", - photo: "https://media.robolist.xyz/rmd_x8.png", - }, - ], - }; + const [partsData, setParts] = useState(null); + const [error, setError] = useState(null); + useEffect(() => { + const fetch_parts = async () => { + try { + const partsQuery = await api.getParts(); + setParts(partsQuery); + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } else { + setError("An unexpected error occurred"); + } + } + }; + fetch_parts(); + }, []); + // const response: PartsResponse = { + // parts: [ + // { + // name: "RMD X8", + // owner: "MyActuator", + // description: "6:1 reduction ratio motor", + // part_id: "1", + // images: "https://media.robolist.xyz/rmd_x8.png", + // }, + // ], + // }; const navigate = useNavigate(); + useEffect(() => { + if (error) { + navigate("/404"); // Redirect to a 404 page + } + }, [error, navigate]); + + if (!partsData) { + return

Loading

; + } + return ( <> @@ -34,22 +84,22 @@ const Parts = () => { - {response.robots.map(({ name, owner, description, id, photo }, key) => ( - - navigate(`/part/${id}`)}> - {photo && ( + {partsData.map((part) => ( + + navigate(`/part/${part.part_id}`)}> + {part.images[0].url && ( )} - {name} + {part.name} - {owner} + {part.owner} - {description} + {part.description} diff --git a/frontend/src/pages/RobotDetails.tsx b/frontend/src/pages/RobotDetails.tsx index 596d7046..91558370 100644 --- a/frontend/src/pages/RobotDetails.tsx +++ b/frontend/src/pages/RobotDetails.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import api from "hooks/api"; +import { useEffect, useState } from "react"; import { Breadcrumb, Button, @@ -22,71 +23,50 @@ interface RobotDetailsResponse { const RobotDetails = () => { const { id } = useParams(); const [show, setShow] = useState(false); + const [robot, setRobot] = useState(null); const [imageIndex, setImageIndex] = useState(0); + const [error, setError] = useState(null); const handleClose = () => setShow(false); const handleShow = () => setShow(true); - // This is a placeholder before the backend is hooked up. - const response: RobotDetailsResponse = { - name: "Stompy", - owner: "K-Scale Labs", - description: `Stompy is an open-source humanoid robot that anyone can 3D print. - -## Purpose - -Stompy is designed to be a versatile platform for research and development in legged robotics. - -## Links - -- [Wiki Entry](https://humanoids.wiki/w/Stompy) + useEffect(() => { + const fetchRobot = async () => { + try { + const robotData = await api.getRobotById(id); + setRobot(robotData); + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } else { + setError("An unexpected error occurred"); + } + } + }; + fetchRobot(); + }, [id]); -### Full Body Sim Artifacts - -- [URDF (with STLs)](https://media.kscale.dev/stompy/latest_stl_urdf.tar.gz) -- [URDF (with OBJs)](https://media.kscale.dev/stompy/latest_obj_urdf.tar.gz) -- [MJCF](https://media.kscale.dev/stompy/latest_mjcf.tar.gz) + const navigate = useNavigate(); -### Single Arm Sim Artifacts + useEffect(() => { + if (error) { + navigate("/404"); // Redirect to a 404 page + } + }, [error, navigate]); -- [URDF (with STLs)](https://media.kscale.dev/stompy/arm_latest_stl_urdf.tar.gz) -- [URDF (with OBJs)](https://media.kscale.dev/stompy/arm_latest_obj_urdf.tar.gz) -- [MJCF](https://media.kscale.dev/stompy/arm_latest_mjcf.tar.gz) -`, - images: [ - { - url: "https://media.robolist.xyz/stompy.png", - caption: "Stompy the robot 1", - }, - { - url: "https://media.robolist.xyz/stompy.png", - caption: "Stompy the robot 2", - }, - { - url: "https://media.robolist.xyz/stompy.png", - caption: "Stompy the robot 3", - }, - ], - bom: [ - { - name: "Actuator", - id: "1234", - quantity: 10, - price: 100, - }, - { - name: "Sensor", - id: "5678", - quantity: 5, - price: 50, - }, - ], + if (!robot) { + return

Loading

; + } + const response: RobotDetailsResponse = { + name: robot?.name, + owner: robot?.owner, + description: robot?.description, + images: robot?.images, + bom: robot?.bom, }; const { name, owner, description, images } = response; - const navigate = useNavigate(); - return ( <> diff --git a/frontend/src/pages/RobotForm.tsx b/frontend/src/pages/RobotForm.tsx new file mode 100644 index 00000000..24b5938c --- /dev/null +++ b/frontend/src/pages/RobotForm.tsx @@ -0,0 +1,205 @@ +import api, { Bom, Image, Robot } from "hooks/api"; +import React, { ChangeEvent, FormEvent, useState } from "react"; +import { Button, Col, Form, Row } from "react-bootstrap"; + +const RobotForm: React.FC = () => { + const [message, setMessage] = useState(null); + const [robot_name, setName] = useState(""); + const [robot_description, setDescription] = useState(""); + const [robot_bom, setBom] = useState([]); + const [robot_images, setImages] = useState([]); + + const handleImageChange = ( + index: number, + e: ChangeEvent, + ) => { + const { name, value } = e.target; + const newImages = [...robot_images]; + newImages[index][name as keyof Image] = value; + setImages(newImages); + }; + + const handleAddImage = () => { + setImages([...robot_images, { url: "", caption: "" }]); + }; + + const handleRemoveImage = (index: number) => { + const newImages = robot_images.filter((_, i) => i !== index); + setImages(newImages); + }; + + const handleBomChange = ( + index: number, + e: ChangeEvent, + ) => { + const { name, value } = e.target; + const newBom = [...robot_bom]; + if (name === "quantity" || name === "price") { + newBom[index][name as "quantity" | "price"] = Number(value); + } else { + newBom[index][name as "name"] = value; + } + + setBom(newBom); + }; + + const handleAddBom = () => { + setBom([...robot_bom, { id: "", name: "", price: 0, quantity: 0 }]); + }; + + const handleRemoveBom = (index: number) => { + const newBom = robot_bom.filter((_, i) => i !== index); + setBom(newBom); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (robot_images.length === 0) { + setMessage("Please upload at least one image."); + return; + } + const newFormData: Robot = { + robot_id: "", + name: robot_name, + description: robot_description, + owner: "Bob", + bom: robot_bom, + images: robot_images, + }; + try { + await api.addRobot(newFormData); + setMessage(`Robot added successfully.`); + } catch (error) { + setMessage("Error adding robot "); + } + }; + + return ( + +

Add a New Robot

+ {message &&

{message}

} +
+ Name: + { + setName(e.target.value); + }} + value={robot_name} + required + /> + Description: + { + setDescription(e.target.value); + }} + value={robot_description} + required + /> + Images: + {robot_images.map((image, index) => ( + + + handleImageChange(index, e)} + required + /> + handleImageChange(index, e)} + required + /> + + + + + + ))} + + + + Bill of Materials: + {robot_bom.map((bom, index) => ( + + + Part Name: + handleBomChange(index, e)} + required + /> + Quantity: + handleBomChange(index, e)} + required + /> + Individual Price: + handleBomChange(index, e)} + required + /> + + + + + + ))} + + + + Submit: + + + + +
+ ); +}; + +export default RobotForm; diff --git a/frontend/src/pages/Robots.tsx b/frontend/src/pages/Robots.tsx index f74d3c9c..a1a1318f 100644 --- a/frontend/src/pages/Robots.tsx +++ b/frontend/src/pages/Robots.tsx @@ -1,31 +1,39 @@ +import api, { Robot } from "hooks/api"; +import { useEffect, useState } from "react"; import { Breadcrumb, Card, Col, Row } from "react-bootstrap"; import { useNavigate } from "react-router-dom"; -interface RobotsResponse { - robots: { - name: string; - owner: string; - description: string; - id: string; - photo?: string; - }[]; -} - const Robots = () => { - const response: RobotsResponse = { - robots: [ - { - name: "Stompy", - owner: "K-Scale Labs", - description: "An open-source humanoid robot costing less than $10k", - id: "1", - photo: "https://media.robolist.xyz/stompy.png", - }, - ], - }; - + const [robotsData, setRobot] = useState(null); + const [error, setError] = useState(null); + useEffect(() => { + const fetch_robots = async () => { + try { + const robotsQuery = await api.getRobots(); + setRobot(robotsQuery); + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } else { + setError("An unexpected error occurred"); + } + } + }; + fetch_robots(); + }, []); const navigate = useNavigate(); + useEffect(() => { + if (error) { + console.log(error); + navigate("/404"); // Redirect to a 404 page + } + }, [error, navigate]); + + if (!robotsData) { + return

Loading

; + } + return ( <> @@ -34,22 +42,22 @@ const Robots = () => { - {response.robots.map(({ name, owner, description, id, photo }, key) => ( - - navigate(`/robot/${id}`)}> - {photo && ( + {robotsData.map((robot) => ( + + navigate(`/robot/${robot.robot_id}`)}> + {robot.images[0] && ( )} - {name} + {robot.name} - {owner} + {robot.owner} - {description} + {robot.description} diff --git a/store/app/api/routers/main.py b/store/app/api/routers/main.py index e65a241f..56c1f904 100644 --- a/store/app/api/routers/main.py +++ b/store/app/api/routers/main.py @@ -1,18 +1,160 @@ """Defines the main API endpoint.""" import logging +from typing import Annotated, Dict, List -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, Depends, FastAPI, HTTPException, status +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from store.app.api.crypto import get_new_user_id +from store.app.api.db import Crud from store.app.api.routers.users import users_router +from store.settings import settings logger = logging.getLogger(__name__) +app = FastAPI() + api_router = APIRouter() +app.add_middleware( + CORSMiddleware, + allow_origins=[settings.site.homepage], # Allow all origins + allow_credentials=True, + allow_methods=["*"], # Allow all methods + allow_headers=["*"], # Allow all headers +) +app.include_router(api_router, prefix="/api") api_router.include_router(users_router, prefix="/users", tags=["users"]) +class Bom(BaseModel): + id: str + name: str + quantity: int + price: int + + +class Image(BaseModel): + caption: str + url: str + + +class Robot(BaseModel): + name: str + owner: str + description: str + bom: List[Bom] + images: List[Image] + robot_id: str + + +class PurchaseLink(BaseModel): + url: str + price: int + name: str + + +class UsedBy(BaseModel): + name: str + id: str + stars: int + + +class Part(BaseModel): + name: str + owner: str + description: str + images: List[Image] + part_id: str + used_by: List[UsedBy] + purchase_links: List[PurchaseLink] + + +@api_router.options("/add/robot/") +async def options_add_robot() -> Dict[str, str]: + return {"message": "Options request allowed"} + + +async def verify_table_exists(table_name: str, crud: Crud) -> bool: + try: + table_names = [table.name for table in await crud.db.tables.all()] + logger.debug(f"Found tables: {table_names}") + return table_name in table_names + except Exception as e: + logger.error(f"Error checking table existence: {e}") + return False + + +@api_router.get("/robots") +async def list_robots(crud: Annotated[Crud, Depends(Crud.get)]) -> List[Robot]: + trace = "" + if not verify_table_exists("Robots", crud): + raise HTTPException(status_code=404, detail="Table not found") + + table = await crud.db.Table("Robots") + response = await table.scan() + + trace += "Scanned table: Robots\n" + trace += f"ResponseMetadata: {response['ResponseMetadata']}\n" + trace += f"Full Response: {response}\n" + + if "Items" in response: + robots = response["Items"] + return [Robot.model_validate(robot) for robot in robots] + else: + raise HTTPException(status_code=404, detail=f"No robots found. Trace: {trace}") + + +@api_router.get("/robots/{robot_id}") +async def get_robot(robot_id: str, crud: Annotated[Crud, Depends(Crud.get)]) -> Robot: + table = await crud.db.Table("Robots") + response = await table.get_item(Key={"robot_id": robot_id}) + if "Item" in response: + return Robot.model_validate(response["Item"]) + else: + raise HTTPException(status_code=404, detail="Robot not found") + + +@api_router.get("/parts/{part_id}") +async def get_part(part_id: str, crud: Annotated[Crud, Depends(Crud.get)]) -> Part: + table = await crud.db.Table("Parts") + response = await table.get_item(Key={"part_id": part_id}) + if "Item" in response: + return Part.model_validate(response["Item"]) + else: + raise HTTPException(status_code=404, detail="Part not found") + + +@api_router.get("/parts") +async def list_parts(crud: Annotated[Crud, Depends(Crud.get)]) -> List[Part]: + trace = "" + if not verify_table_exists("Parts", crud): + raise HTTPException(status_code=404, detail="Table not found") + + table = await crud.db.Table("Parts") + response = await table.scan() + + trace += "Scanned table: Parts\n" + trace += f"ResponseMetadata: {response['ResponseMetadata']}\n" + trace += f"Full Response: {response}\n" + + if "Items" in response: + parts = response["Items"] + return [Part.model_validate(part) for part in parts] + else: + raise HTTPException(status_code=404, detail=f"No parts found. Trace: {trace}") + + +@api_router.post("/add/robot/") +async def add_robot(robot: Robot, crud: Annotated[Crud, Depends(Crud.get)]) -> Dict[str, str]: + table = await crud.db.Table("Robots") + robot.robot_id = str(get_new_user_id()) + await table.put_item(Item=robot.model_dump()) + return {"message": "Robot added successfully"} + + # Returns a 404 response for all other paths. @api_router.get("/{path:path}") async def not_found(path: str) -> dict[str, str]: diff --git a/store/app/api/routers/test_cred.py b/store/app/api/routers/test_cred.py new file mode 100644 index 00000000..09c1341a --- /dev/null +++ b/store/app/api/routers/test_cred.py @@ -0,0 +1,25 @@ +"""Tests credentials.""" + +import boto3 +from botocore.exceptions import NoCredentialsError, PartialCredentialsError + + +def test_dynamodb_connection() -> None: + print("Testing DynamoDB connection...") + try: + # Initialize DynamoDB client + dynamodb = boto3.resource("dynamodb") + # Attempt to list tables as a test + tables = list(dynamodb.tables.all()) + print(f"Connected to DynamoDB. Found tables: {[table.name for table in tables]}") + print(f"endpoint url is {dynamodb.meta.client.meta.endpoint_url}") + except NoCredentialsError: + print("No credentials found. Please configure your AWS credentials.") + except PartialCredentialsError: + print("Incomplete credentials found. Please check your AWS credentials.") + except Exception as e: + print(f"An error occurred: {e}") + + +if __name__ == "__main__": + test_dynamodb_connection() diff --git a/store/app/api/routers/users.py b/store/app/api/routers/users.py index cb8610a5..19c7e83f 100644 --- a/store/app/api/routers/users.py +++ b/store/app/api/routers/users.py @@ -12,8 +12,8 @@ from store.app.api.crypto import get_new_api_key, get_new_user_id from store.app.api.db import Crud -from store.app.api.email import OneTimePassPayload, send_delete_email, send_otp_email from store.app.api.model import User +from store.app.api.utils.email import OneTimePassPayload, send_delete_email, send_otp_email logger = logging.getLogger(__name__) @@ -28,8 +28,7 @@ def set_token_cookie(response: Response, token: str, key: str) -> None: value=token, httponly=True, secure=False, - # samesite="strict", - samesite="none", + samesite="lax", ) diff --git a/store/app/api/utils/__init__.py b/store/app/api/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/store/app/api/email.py b/store/app/api/utils/email.py similarity index 100% rename from store/app/api/email.py rename to store/app/api/utils/email.py diff --git a/tests/api/test_users.py b/tests/api/test_users.py index fa7624ef..854c0feb 100644 --- a/tests/api/test_users.py +++ b/tests/api/test_users.py @@ -6,7 +6,7 @@ from pytest_mock.plugin import MockType from store.app.api.db import create_tables -from store.app.api.email import OneTimePassPayload +from store.app.api.utils.email import OneTimePassPayload def test_user_auth_functions(app_client: TestClient, mock_send_email: MockType) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index c55b1be7..2e2d47e5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -77,14 +77,14 @@ def app_client() -> Generator[TestClient, None, None]: @pytest.fixture(autouse=True) def mock_send_email(mocker: MockerFixture) -> MockType: - mock = mocker.patch("store.app.api.email.send_email") + mock = mocker.patch("store.app.api.utils.email.send_email") mock.return_value = None return mock @pytest.fixture() def authenticated_user(app_client: TestClient) -> tuple[TestClient, str, str]: - from store.app.api.email import OneTimePassPayload + from store.app.api.utils.email import OneTimePassPayload test_email = "test@example.com"