diff --git a/SPECS/feature-template.md b/SPECS/feature-template.md index 7dbc70a5..250995dd 100644 --- a/SPECS/feature-template.md +++ b/SPECS/feature-template.md @@ -1,14 +1,38 @@ -# Feature Spec: +# Feature Spec: Stock Retrieval ## Goal -- +- Create a simple fullstack application that allows a user to view stock prices. The application will have a backend API that retrieves stock prices from a third-party API and a frontend that displays the stock prices to the user. ## Scope - In: + - Backend API to retrieve stock prices from a third-party API. + - Frontend to display stock prices to the user. + - Testing framework for both frontend and backend. + - Documentation on how to run the application and tests. - Out: + - User authentication and authorization + - Portfolio tracking or watchlists + - Real-time stock price updates via WebSocket + - Historical price charting ## Requirements -- +- Backend must expose a REST API endpoint to fetch stock prices +- Frontend must allow users to search for stocks by ticker symbol +- Stock data should include price, change percentage, and timestamp +- API responses should be cached to minimize third-party API calls +- Error handling for invalid ticker symbols and API failures +- Application should run on localhost with documented ports +- Unit tests with minimum 80% code coverage for backend +- Component tests for React frontend components +- Must be ran with docker ## Acceptance Criteria -- [ ] \ No newline at end of file +- [ ] Backend API returns stock price data in JSON format +- [ ] Frontend displays stock ticker, current price, and price change +- [ ] Users can search for stocks by entering a ticker symbol +- [ ] Invalid ticker symbols display an appropriate error message +- [ ] API failures gracefully handle and display error to user +- [ ] Backend tests pass with 80% or greater coverage +- [ ] Frontend component tests pass +- [ ] Submission_README includes setup instructions for both frontend and backend as well as explain my thought process. +- [ ] Application runs successfully on `http://localhost:5050` (backend) and `http://localhost:3000` (frontend) \ No newline at end of file diff --git a/Submission_README.md b/Submission_README.md new file mode 100644 index 00000000..00b40967 --- /dev/null +++ b/Submission_README.md @@ -0,0 +1,22 @@ +# Candidate Assessment: Spec-Driven Development With Codegen Tools + +## Approach +- I wanted to create a simple application that allowed a user to create an account, login, and view a dashboard that allows them to look at stock prices. +- I choose to use Claude 4.6 Opus for code generation, as it has a good understanding of both frontend and backend development. +- I started by defining the specifications for the application, including the user stories and the API endpoints. +- I then used Claude to generate the initial code for both the frontend and backend, and iteratively refined the code based on testing and feedback. +- I also created a test suite to validate the functionality of the application, including unit tests for the backend and integration tests for the frontend. + +## How To Run the Application +1. Clone the repository to your local machine. +2. Navigate to the project directory and install the dependencies for both the frontend and backend. + - For the frontend, run `npm install` in the `frontend` directory. + - For the backend, run `pip install -r requirements.txt` in the `backend` directory. +3. You can run the full application by running docker-compose up from the root directory, which will start both the frontend and backend services. +4. Alternatively, you can run the frontend and backend separately: + - To run the backend, navigate to the `backend` directory and run `python run.py`. This will start the backend server on `http://localhost:5050`. + - To Run the frontend, navigate to the `frontend` directory and run `npm start`. This will start the frontend development server on `http://localhost:3000`. +5. Once both servers are running, you can access the application by navigating to `http://localhost:3000` in your web browser. +6. You can create an account, login, and view the dashboard to see stock prices. The application is designed to be simple and easy to use, with a focus on demonstrating the capabilities of code generation tools in a spec-driven development process. + - If you have any issues pulling a stock price, make sure you disable any ad blockers or browser extensions that might interfere with API calls. For example PI Hole interferes with the stock price API calls, so you may need to disable it temporarily to see the stock prices in the dashboard. +7. To run the tests, navigate to the `backend` directory and run `python -m pytest tests/ -v`. This will execute the test suite and provide feedback on the functionality of the backend API endpoints. You can also run it with coverage using `python -m pytest tests/ -v --cov=app --cov-report=term-missing`. You can test the frontend by navigating to the `frontend` directory and running `npm test -- --watchAll=false` or `npm test -- --watchAll=false --coverage` for coverage. \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..3c8698ba --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.9.6-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5050 + +CMD ["python", "run.py"] diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 00000000..b4a1c8a7 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,32 @@ +from flask import Flask +from flask_cors import CORS +from flask_sqlalchemy import SQLAlchemy +from flask_jwt_extended import JWTManager + +db = SQLAlchemy() +jwt = JWTManager() + + +def create_app(config_class=None): + app = Flask(__name__) + + if config_class: + app.config.from_object(config_class) + else: + from app.config import Config + app.config.from_object(Config) + + CORS(app, resources={r"/api/*": {"origins": "*"}}) + db.init_app(app) + jwt.init_app(app) + + from app.auth import auth_bp + from app.stock import stock_bp + + app.register_blueprint(auth_bp, url_prefix="/api/auth") + app.register_blueprint(stock_bp, url_prefix="/api/stock") + + with app.app_context(): + db.create_all() + + return app diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 00000000..d33a38a9 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,71 @@ +from flask import Blueprint, request, jsonify +from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity +from app import db +from app.models import User + +auth_bp = Blueprint("auth", __name__) + + +@auth_bp.route("/register", methods=["POST"]) +def register(): + data = request.get_json() + + if not data: + return jsonify({"error": "No data provided"}), 400 + + username = data.get("username") + email = data.get("email") + password = data.get("password") + + if not username or not email or not password: + return jsonify({"error": "Username, email, and password are required"}), 400 + + if len(password) < 6: + return jsonify({"error": "Password must be at least 6 characters"}), 400 + + if User.query.filter_by(username=username).first(): + return jsonify({"error": "Username already exists"}), 409 + + if User.query.filter_by(email=email).first(): + return jsonify({"error": "Email already registered"}), 409 + + user = User(username=username, email=email) + user.set_password(password) + db.session.add(user) + db.session.commit() + + return jsonify({"message": "User registered successfully", "user": user.to_dict()}), 201 + + +@auth_bp.route("/login", methods=["POST"]) +def login(): + data = request.get_json() + + if not data: + return jsonify({"error": "No data provided"}), 400 + + username = data.get("username") + password = data.get("password") + + if not username or not password: + return jsonify({"error": "Username and password are required"}), 400 + + user = User.query.filter_by(username=username).first() + + if not user or not user.check_password(password): + return jsonify({"error": "Invalid username or password"}), 401 + + access_token = create_access_token(identity=str(user.id)) + return jsonify({"access_token": access_token, "user": user.to_dict()}), 200 + + +@auth_bp.route("/me", methods=["GET"]) +@jwt_required() +def get_current_user(): + user_id = get_jwt_identity() + user = User.query.get(int(user_id)) + + if not user: + return jsonify({"error": "User not found"}), 404 + + return jsonify({"user": user.to_dict()}), 200 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 00000000..c6305f7c --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,14 @@ +import os + + +class Config: + SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key-change-in-production") + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///app.db") + SQLALCHEMY_TRACK_MODIFICATIONS = False + JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "jwt-secret-change-in-production") + + +class TestConfig(Config): + TESTING = True + SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" + JWT_SECRET_KEY = "test-jwt-secret" diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 00000000..4fcc6222 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,24 @@ +from app import db +from werkzeug.security import generate_password_hash, check_password_hash + + +class User(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String(120), unique=True, nullable=False) + password_hash = db.Column(db.String(256), nullable=False) + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + def to_dict(self): + return { + "id": self.id, + "username": self.username, + "email": self.email, + } diff --git a/backend/app/stock.py b/backend/app/stock.py new file mode 100644 index 00000000..338613dc --- /dev/null +++ b/backend/app/stock.py @@ -0,0 +1,51 @@ +from flask import Blueprint, request, jsonify +from flask_jwt_extended import jwt_required +import yfinance as yf + +stock_bp = Blueprint("stock", __name__) + + +def fetch_stock_price(symbol): + """Fetch current stock price for a given symbol using yfinance.""" + ticker = yf.Ticker(symbol) + info = ticker.fast_info + + current_price = getattr(info, "last_price", None) + previous_close = getattr(info, "previous_close", None) + market_cap = getattr(info, "market_cap", None) + + if current_price is None and previous_close is None: + return None + + return { + "symbol": symbol.upper(), + "price": round(current_price, 2) if current_price else None, + "previous_close": round(previous_close, 2) if previous_close else None, + "market_cap": market_cap, + "currency": getattr(info, "currency", "USD"), + } + + +@stock_bp.route("/price", methods=["GET"]) +@jwt_required() +def get_stock_price(): + symbol = request.args.get("symbol") + + if not symbol: + return jsonify({"error": "Stock symbol is required"}), 400 + + symbol = symbol.strip().upper() + + if not symbol.isalpha() or len(symbol) > 5: + return jsonify({"error": "Invalid stock symbol"}), 400 + + try: + stock_data = fetch_stock_price(symbol) + + if stock_data is None: + return jsonify({"error": f"Could not find stock data for {symbol}"}), 404 + + return jsonify({"stock": stock_data}), 200 + + except Exception as e: + return jsonify({"error": f"Failed to fetch stock data: {str(e)}"}), 500 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 00000000..c33c1ec4 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,8 @@ +Flask==2.3.3 +Flask-Cors==4.0.0 +Flask-SQLAlchemy==3.1.1 +Flask-JWT-Extended==4.6.0 +Werkzeug==2.3.7 +yfinance==1.1.0 +pytest==7.4.3 +pytest-cov==4.1.0 diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 00000000..d6d58550 --- /dev/null +++ b/backend/run.py @@ -0,0 +1,6 @@ +from app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..723cc580 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,39 @@ +import pytest +from app import create_app, db +from app.config import TestConfig +from app.models import User + + +@pytest.fixture +def app(): + app = create_app(TestConfig) + with app.app_context(): + db.create_all() + yield app + db.session.remove() + db.drop_all() + + +@pytest.fixture +def client(app): + return app.test_client() + + +@pytest.fixture +def sample_user(app): + with app.app_context(): + user = User(username="testuser", email="test@example.com") + user.set_password("password123") + db.session.add(user) + db.session.commit() + return user.to_dict() + + +@pytest.fixture +def auth_header(client, sample_user): + response = client.post( + "/api/auth/login", + json={"username": "testuser", "password": "password123"}, + ) + token = response.get_json()["access_token"] + return {"Authorization": f"Bearer {token}"} diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 00000000..119c2273 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,130 @@ +import json + + +class TestRegister: + def test_register_success(self, client): + response = client.post( + "/api/auth/register", + json={ + "username": "newuser", + "email": "new@example.com", + "password": "password123", + }, + ) + assert response.status_code == 201 + data = response.get_json() + assert data["message"] == "User registered successfully" + assert data["user"]["username"] == "newuser" + assert data["user"]["email"] == "new@example.com" + assert "id" in data["user"] + + def test_register_missing_fields(self, client): + response = client.post( + "/api/auth/register", + json={"username": "newuser"}, + ) + assert response.status_code == 400 + assert "required" in response.get_json()["error"] + + def test_register_short_password(self, client): + response = client.post( + "/api/auth/register", + json={ + "username": "newuser", + "email": "new@example.com", + "password": "123", + }, + ) + assert response.status_code == 400 + assert "at least 6 characters" in response.get_json()["error"] + + def test_register_duplicate_username(self, client, sample_user): + response = client.post( + "/api/auth/register", + json={ + "username": "testuser", + "email": "different@example.com", + "password": "password123", + }, + ) + assert response.status_code == 409 + assert "Username already exists" in response.get_json()["error"] + + def test_register_duplicate_email(self, client, sample_user): + response = client.post( + "/api/auth/register", + json={ + "username": "differentuser", + "email": "test@example.com", + "password": "password123", + }, + ) + assert response.status_code == 409 + assert "Email already registered" in response.get_json()["error"] + + def test_register_no_data(self, client): + response = client.post( + "/api/auth/register", + content_type="application/json", + ) + assert response.status_code == 400 + + +class TestLogin: + def test_login_success(self, client, sample_user): + response = client.post( + "/api/auth/login", + json={"username": "testuser", "password": "password123"}, + ) + assert response.status_code == 200 + data = response.get_json() + assert "access_token" in data + assert data["user"]["username"] == "testuser" + + def test_login_wrong_password(self, client, sample_user): + response = client.post( + "/api/auth/login", + json={"username": "testuser", "password": "wrongpassword"}, + ) + assert response.status_code == 401 + assert "Invalid" in response.get_json()["error"] + + def test_login_nonexistent_user(self, client): + response = client.post( + "/api/auth/login", + json={"username": "nobody", "password": "password123"}, + ) + assert response.status_code == 401 + + def test_login_missing_fields(self, client): + response = client.post( + "/api/auth/login", + json={"username": "testuser"}, + ) + assert response.status_code == 400 + + def test_login_no_data(self, client): + response = client.post( + "/api/auth/login", + content_type="application/json", + ) + assert response.status_code == 400 + + +class TestGetCurrentUser: + def test_get_current_user_success(self, client, auth_header): + response = client.get("/api/auth/me", headers=auth_header) + assert response.status_code == 200 + data = response.get_json() + assert data["user"]["username"] == "testuser" + + def test_get_current_user_no_token(self, client): + response = client.get("/api/auth/me") + assert response.status_code == 401 + + def test_get_current_user_invalid_token(self, client): + response = client.get( + "/api/auth/me", + headers={"Authorization": "Bearer invalidtoken"}, + ) + assert response.status_code == 422 diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py new file mode 100644 index 00000000..c2a972f9 --- /dev/null +++ b/backend/tests/test_models.py @@ -0,0 +1,73 @@ +from app.models import User +from app import db + + +class TestUserModel: + def test_create_user(self, app): + with app.app_context(): + user = User(username="john", email="john@example.com") + user.set_password("secret123") + db.session.add(user) + db.session.commit() + + assert user.id is not None + assert user.username == "john" + assert user.email == "john@example.com" + + def test_password_hashing(self, app): + with app.app_context(): + user = User(username="john", email="john@example.com") + user.set_password("secret123") + + assert user.password_hash != "secret123" + assert user.check_password("secret123") is True + assert user.check_password("wrongpassword") is False + + def test_to_dict(self, app): + with app.app_context(): + user = User(username="john", email="john@example.com") + user.set_password("secret123") + db.session.add(user) + db.session.commit() + + user_dict = user.to_dict() + assert "id" in user_dict + assert user_dict["username"] == "john" + assert user_dict["email"] == "john@example.com" + assert "password_hash" not in user_dict + + def test_unique_username(self, app): + with app.app_context(): + user1 = User(username="john", email="john1@example.com") + user1.set_password("secret123") + db.session.add(user1) + db.session.commit() + + user2 = User(username="john", email="john2@example.com") + user2.set_password("secret456") + db.session.add(user2) + + import sqlalchemy + try: + db.session.commit() + assert False, "Should have raised an integrity error" + except sqlalchemy.exc.IntegrityError: + db.session.rollback() + + def test_unique_email(self, app): + with app.app_context(): + user1 = User(username="john1", email="john@example.com") + user1.set_password("secret123") + db.session.add(user1) + db.session.commit() + + user2 = User(username="john2", email="john@example.com") + user2.set_password("secret456") + db.session.add(user2) + + import sqlalchemy + try: + db.session.commit() + assert False, "Should have raised an integrity error" + except sqlalchemy.exc.IntegrityError: + db.session.rollback() diff --git a/backend/tests/test_stock.py b/backend/tests/test_stock.py new file mode 100644 index 00000000..c73cb4b3 --- /dev/null +++ b/backend/tests/test_stock.py @@ -0,0 +1,116 @@ +from unittest.mock import patch, MagicMock + + +class TestStockPrice: + def test_get_stock_price_no_symbol(self, client, auth_header): + response = client.get("/api/stock/price", headers=auth_header) + assert response.status_code == 400 + assert "required" in response.get_json()["error"] + + def test_get_stock_price_invalid_symbol(self, client, auth_header): + response = client.get("/api/stock/price?symbol=123", headers=auth_header) + assert response.status_code == 400 + assert "Invalid" in response.get_json()["error"] + + def test_get_stock_price_symbol_too_long(self, client, auth_header): + response = client.get("/api/stock/price?symbol=ABCDEF", headers=auth_header) + assert response.status_code == 400 + assert "Invalid" in response.get_json()["error"] + + def test_get_stock_price_unauthorized(self, client): + response = client.get("/api/stock/price?symbol=AAPL") + assert response.status_code == 401 + + @patch("app.stock.fetch_stock_price") + def test_get_stock_price_success(self, mock_fetch, client, auth_header): + mock_fetch.return_value = { + "symbol": "AAPL", + "price": 150.25, + "previous_close": 149.50, + "market_cap": 2500000000000, + "currency": "USD", + } + response = client.get("/api/stock/price?symbol=AAPL", headers=auth_header) + assert response.status_code == 200 + data = response.get_json() + assert data["stock"]["symbol"] == "AAPL" + assert data["stock"]["price"] == 150.25 + mock_fetch.assert_called_once_with("AAPL") + + @patch("app.stock.fetch_stock_price") + def test_get_stock_price_not_found(self, mock_fetch, client, auth_header): + mock_fetch.return_value = None + response = client.get("/api/stock/price?symbol=ZZZZZ", headers=auth_header) + assert response.status_code == 404 + + @patch("app.stock.fetch_stock_price") + def test_get_stock_price_api_error(self, mock_fetch, client, auth_header): + mock_fetch.side_effect = Exception("API Error") + response = client.get("/api/stock/price?symbol=AAPL", headers=auth_header) + assert response.status_code == 500 + + @patch("app.stock.fetch_stock_price") + def test_get_stock_price_lowercase_symbol(self, mock_fetch, client, auth_header): + mock_fetch.return_value = { + "symbol": "MSFT", + "price": 300.00, + "previous_close": 299.00, + "market_cap": 2000000000000, + "currency": "USD", + } + response = client.get("/api/stock/price?symbol=msft", headers=auth_header) + assert response.status_code == 200 + mock_fetch.assert_called_once_with("MSFT") + + +class TestFetchStockPrice: + @patch("app.stock.yf.Ticker") + def test_fetch_stock_price_returns_data(self, mock_ticker_class, app): + mock_info = MagicMock() + mock_info.last_price = 150.256 + mock_info.previous_close = 149.501 + mock_info.market_cap = 2500000000000 + mock_info.currency = "USD" + + mock_ticker = MagicMock() + mock_ticker.fast_info = mock_info + mock_ticker_class.return_value = mock_ticker + + from app.stock import fetch_stock_price + + result = fetch_stock_price("AAPL") + + assert result is not None + assert result["symbol"] == "AAPL" + assert result["price"] == 150.26 + assert result["previous_close"] == 149.50 + assert result["currency"] == "USD" + + @patch("app.stock.yf.Ticker") + def test_fetch_stock_price_no_data(self, mock_ticker_class, app): + mock_info = MagicMock() + mock_info.last_price = None + mock_info.previous_close = None + mock_info.market_cap = None + mock_info.currency = "USD" + # Override getattr behavior for None values + type(mock_info).last_price = None + type(mock_info).previous_close = None + + mock_ticker = MagicMock() + mock_ticker.fast_info = mock_info + mock_ticker_class.return_value = mock_ticker + + from app.stock import fetch_stock_price + + # When both last_price and previous_close are None via getattr, returns None + with patch("app.stock.getattr", side_effect=[None, None, None]) as mock_getattr: + # We need a different approach - let's mock the fast_info object properly + pass + + # Simpler: just ensure we test the function handles missing data + mock_info_empty = MagicMock(spec=[]) # No attributes at all + mock_ticker.fast_info = mock_info_empty + + result = fetch_stock_price("INVALID") + assert result is None diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..d9264204 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +version: "3.8" + +services: + backend: + build: ./backend + ports: + - "5050:5050" + environment: + - SECRET_KEY=docker-secret-key-change-me + - JWT_SECRET_KEY=docker-jwt-secret-change-me + - DATABASE_URL=sqlite:///app.db + volumes: + - backend-data:/app/instance + + frontend: + build: ./frontend + ports: + - "3000:3000" + environment: + - REACT_APP_API_URL=http://localhost:5050/api + depends_on: + - backend + +volumes: + backend-data: diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 00000000..4fda0f75 --- /dev/null +++ b/frontend/.env @@ -0,0 +1 @@ +REACT_APP_API_URL=http://localhost:5050/api diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..8ec9a850 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..b3e3f7c4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "stock-price-app", + "version": "1.0.0", + "private": true, + "dependencies": { + "axios": "^1.6.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.1", + "react-scripts": "5.0.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.1.4", + "@testing-library/react": "^14.1.2", + "@testing-library/user-event": "^14.5.1", + "jest": "^27.5.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "browserslist": { + "production": [">0.2%", "not dead", "not op_mini all"], + "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"] + } +} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 00000000..8138c6c4 --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,12 @@ + + + + + + Stock Price Lookup + + + +
+ + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 00000000..cb7ba788 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,230 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background-color: #f5f7fa; + color: #333; +} + +.app { + min-height: 100vh; +} + +.app-header { + background: linear-gradient(135deg, #1a237e, #283593); + color: white; + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.app-header h1 { + font-size: 1.5rem; +} + +.nav-links { + display: flex; + gap: 0.5rem; +} + +.nav-user { + display: flex; + align-items: center; + gap: 1rem; +} + +.btn { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + text-decoration: none; + background: rgba(255, 255, 255, 0.2); + color: white; + transition: background 0.2s; +} + +.btn:hover { + background: rgba(255, 255, 255, 0.3); +} + +.btn-primary { + background: #1565c0; + color: white; + width: 100%; + padding: 0.75rem; + font-size: 1rem; +} + +.btn-primary:hover { + background: #0d47a1; +} + +.btn-logout { + background: rgba(255, 255, 255, 0.15); + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.app-main { + max-width: 600px; + margin: 2rem auto; + padding: 0 1rem; +} + +.form-container { + background: white; + border-radius: 8px; + padding: 2rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.form-container h2 { + margin-bottom: 1.5rem; + text-align: center; + color: #1a237e; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.25rem; + font-weight: 500; + color: #555; +} + +.form-group input { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; +} + +.form-group input:focus { + outline: none; + border-color: #1565c0; + box-shadow: 0 0 0 2px rgba(21, 101, 192, 0.2); +} + +.error-message { + background: #ffebee; + color: #c62828; + padding: 0.75rem; + border-radius: 4px; + margin-bottom: 1rem; + text-align: center; +} + +.success-message { + background: #e8f5e9; + color: #2e7d32; + padding: 0.75rem; + border-radius: 4px; + margin-bottom: 1rem; + text-align: center; +} + +.form-footer { + text-align: center; + margin-top: 1rem; + color: #777; +} + +.form-footer a { + color: #1565c0; + text-decoration: none; +} + +.stock-container { + background: white; + border-radius: 8px; + padding: 2rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.stock-container h2 { + margin-bottom: 1.5rem; + text-align: center; + color: #1a237e; +} + +.stock-search { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.stock-search input { + flex: 1; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + text-transform: uppercase; +} + +.stock-search button { + padding: 0.75rem 1.5rem; + background: #1565c0; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; +} + +.stock-search button:disabled { + background: #90caf9; + cursor: not-allowed; +} + +.stock-result { + background: #f5f7fa; + border-radius: 8px; + padding: 1.5rem; +} + +.stock-result h3 { + font-size: 1.5rem; + color: #1a237e; + margin-bottom: 1rem; +} + +.stock-price { + font-size: 2.5rem; + font-weight: bold; + color: #2e7d32; + margin-bottom: 1rem; +} + +.stock-details { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} + +.stock-detail-item { + display: flex; + justify-content: space-between; + padding: 0.5rem; + background: white; + border-radius: 4px; +} + +.stock-detail-label { + color: #777; +} + +.stock-detail-value { + font-weight: 600; +} diff --git a/frontend/src/App.js b/frontend/src/App.js new file mode 100644 index 00000000..fb441acc --- /dev/null +++ b/frontend/src/App.js @@ -0,0 +1,83 @@ +import React, { useState, useEffect } from 'react'; +import { BrowserRouter as Router, Routes, Route, Navigate, Link } from 'react-router-dom'; +import Login from './components/Login'; +import Register from './components/Register'; +import StockLookup from './components/StockLookup'; +import './App.css'; + +function App() { + const [user, setUser] = useState(null); + + useEffect(() => { + const storedUser = localStorage.getItem('user'); + if (storedUser) { + setUser(JSON.parse(storedUser)); + } + }, []); + + const handleLogin = (userData, token) => { + localStorage.setItem('access_token', token); + localStorage.setItem('user', JSON.stringify(userData)); + setUser(userData); + }; + + const handleLogout = () => { + localStorage.removeItem('access_token'); + localStorage.removeItem('user'); + setUser(null); + }; + + return ( + +
+
+

📈 Stock Price Lookup

+ +
+ +
+ + : + } + /> + : + } + /> + : + } + /> + } + /> + +
+
+
+ ); +} + +export default App; diff --git a/frontend/src/__tests__/App.test.js b/frontend/src/__tests__/App.test.js new file mode 100644 index 00000000..6da860f9 --- /dev/null +++ b/frontend/src/__tests__/App.test.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import App from '../App'; + +describe('App Component', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('renders the app header', () => { + render(); + expect(screen.getByText(/stock price lookup/i)).toBeInTheDocument(); + }); + + test('shows register links when not authenticated', () => { + render(); + expect(screen.getByText('Register')).toBeInTheDocument(); + }); + + test('shows welcome message and logout when authenticated', () => { + localStorage.setItem('user', JSON.stringify({ username: 'testuser' })); + localStorage.setItem('access_token', 'test-token'); + + render(); + expect(screen.getByText(/welcome, testuser/i)).toBeInTheDocument(); + expect(screen.getByText(/logout/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/Login.test.js b/frontend/src/__tests__/Login.test.js new file mode 100644 index 00000000..78e601d7 --- /dev/null +++ b/frontend/src/__tests__/Login.test.js @@ -0,0 +1,104 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BrowserRouter } from 'react-router-dom'; +import Login from '../components/Login'; +import * as api from '../api'; + +jest.mock('../api'); + +const renderLogin = (onLogin = jest.fn()) => { + return render( + + + + ); +}; + +describe('Login Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('renders login form', () => { + renderLogin(); + expect(screen.getByRole('heading', { name: /login/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/username/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument(); + }); + + test('renders link to register page', () => { + renderLogin(); + expect(screen.getByText(/register here/i)).toBeInTheDocument(); + }); + + test('successful login calls onLogin', async () => { + const onLogin = jest.fn(); + api.login.mockResolvedValue({ + data: { + access_token: 'test-token', + user: { id: 1, username: 'testuser', email: 'test@example.com' }, + }, + }); + + renderLogin(onLogin); + + await userEvent.type(screen.getByLabelText(/username/i), 'testuser'); + await userEvent.type(screen.getByLabelText(/password/i), 'password123'); + fireEvent.click(screen.getByRole('button', { name: /login/i })); + + await waitFor(() => { + expect(api.login).toHaveBeenCalledWith('testuser', 'password123'); + expect(onLogin).toHaveBeenCalledWith( + { id: 1, username: 'testuser', email: 'test@example.com' }, + 'test-token' + ); + }); + }); + + test('displays error on failed login', async () => { + api.login.mockRejectedValue({ + response: { data: { error: 'Invalid username or password' } }, + }); + + renderLogin(); + + await userEvent.type(screen.getByLabelText(/username/i), 'baduser'); + await userEvent.type(screen.getByLabelText(/password/i), 'badpass'); + fireEvent.click(screen.getByRole('button', { name: /login/i })); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('Invalid username or password'); + }); + }); + + test('displays generic error when no response data', async () => { + api.login.mockRejectedValue(new Error('Network Error')); + + renderLogin(); + + await userEvent.type(screen.getByLabelText(/username/i), 'user'); + await userEvent.type(screen.getByLabelText(/password/i), 'pass'); + fireEvent.click(screen.getByRole('button', { name: /login/i })); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('Login failed'); + }); + }); + + test('shows loading state while logging in', async () => { + api.login.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 1000)) + ); + + renderLogin(); + + await userEvent.type(screen.getByLabelText(/username/i), 'user'); + await userEvent.type(screen.getByLabelText(/password/i), 'pass'); + fireEvent.click(screen.getByRole('button', { name: /login/i })); + + expect(screen.getByRole('button')).toHaveTextContent('Logging in...'); + expect(screen.getByRole('button')).toBeDisabled(); + }); +}); diff --git a/frontend/src/__tests__/Register.test.js b/frontend/src/__tests__/Register.test.js new file mode 100644 index 00000000..493fed2a --- /dev/null +++ b/frontend/src/__tests__/Register.test.js @@ -0,0 +1,96 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BrowserRouter } from 'react-router-dom'; +import Register from '../components/Register'; +import * as api from '../api'; + +jest.mock('../api'); + +const renderRegister = (onLogin = jest.fn()) => { + return render( + + + + ); +}; + +describe('Register Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('renders registration form', () => { + renderRegister(); + expect(screen.getByRole('heading', { name: /create account/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/^username/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^password/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /create account/i })).toBeInTheDocument(); + }); + + test('renders link to login page', () => { + renderRegister(); + expect(screen.getByText(/login here/i)).toBeInTheDocument(); + }); + + test('shows error when passwords do not match', async () => { + renderRegister(); + + await userEvent.type(screen.getByLabelText(/^username/i), 'newuser'); + await userEvent.type(screen.getByLabelText(/email/i), 'new@example.com'); + await userEvent.type(screen.getByLabelText(/^password/i), 'password123'); + await userEvent.type(screen.getByLabelText(/confirm password/i), 'password456'); + fireEvent.click(screen.getByRole('button', { name: /create account/i })); + + expect(screen.getByRole('alert')).toHaveTextContent('Passwords do not match'); + expect(api.register).not.toHaveBeenCalled(); + }); + + test('successful registration and auto-login', async () => { + const onLogin = jest.fn(); + api.register.mockResolvedValue({ data: { message: 'User registered' } }); + api.login.mockResolvedValue({ + data: { + access_token: 'new-token', + user: { id: 2, username: 'newuser', email: 'new@example.com' }, + }, + }); + + renderRegister(onLogin); + + await userEvent.type(screen.getByLabelText(/^username/i), 'newuser'); + await userEvent.type(screen.getByLabelText(/email/i), 'new@example.com'); + await userEvent.type(screen.getByLabelText(/^password/i), 'password123'); + await userEvent.type(screen.getByLabelText(/confirm password/i), 'password123'); + fireEvent.click(screen.getByRole('button', { name: /create account/i })); + + await waitFor(() => { + expect(api.register).toHaveBeenCalledWith('newuser', 'new@example.com', 'password123'); + expect(api.login).toHaveBeenCalledWith('newuser', 'password123'); + expect(onLogin).toHaveBeenCalledWith( + { id: 2, username: 'newuser', email: 'new@example.com' }, + 'new-token' + ); + }); + }); + + test('displays error on failed registration', async () => { + api.register.mockRejectedValue({ + response: { data: { error: 'Username already exists' } }, + }); + + renderRegister(); + + await userEvent.type(screen.getByLabelText(/^username/i), 'existing'); + await userEvent.type(screen.getByLabelText(/email/i), 'e@example.com'); + await userEvent.type(screen.getByLabelText(/^password/i), 'password123'); + await userEvent.type(screen.getByLabelText(/confirm password/i), 'password123'); + fireEvent.click(screen.getByRole('button', { name: /create account/i })); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('Username already exists'); + }); + }); +}); diff --git a/frontend/src/__tests__/StockLookup.test.js b/frontend/src/__tests__/StockLookup.test.js new file mode 100644 index 00000000..25013ebd --- /dev/null +++ b/frontend/src/__tests__/StockLookup.test.js @@ -0,0 +1,144 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import StockLookup from '../components/StockLookup'; +import * as api from '../api'; + +jest.mock('../api'); + +describe('StockLookup Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('renders stock lookup form', () => { + render(); + expect(screen.getByRole('heading', { name: /stock price lookup/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/stock symbol/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /search/i })).toBeInTheDocument(); + }); + + test('displays stock data on successful search', async () => { + api.getStockPrice.mockResolvedValue({ + data: { + stock: { + symbol: 'AAPL', + price: 150.25, + previous_close: 149.5, + market_cap: 2500000000000, + currency: 'USD', + }, + }, + }); + + render(); + + await userEvent.type(screen.getByLabelText(/stock symbol/i), 'AAPL'); + fireEvent.click(screen.getByRole('button', { name: /search/i })); + + await waitFor(() => { + expect(screen.getByTestId('stock-result')).toBeInTheDocument(); + expect(screen.getByText('AAPL')).toBeInTheDocument(); + expect(screen.getByText('$150.25')).toBeInTheDocument(); + expect(screen.getByText('$149.50')).toBeInTheDocument(); + expect(screen.getByText('$2.50T')).toBeInTheDocument(); + }); + }); + + test('displays error on failed search', async () => { + api.getStockPrice.mockRejectedValue({ + response: { data: { error: 'Could not find stock data for XYZ' } }, + }); + + render(); + + await userEvent.type(screen.getByLabelText(/stock symbol/i), 'XYZ'); + fireEvent.click(screen.getByRole('button', { name: /search/i })); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('Could not find stock data for XYZ'); + }); + }); + + test('converts input to uppercase', async () => { + render(); + const input = screen.getByLabelText(/stock symbol/i); + await userEvent.type(input, 'aapl'); + expect(input).toHaveValue('AAPL'); + }); + + test('shows loading state during search', async () => { + api.getStockPrice.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 1000)) + ); + + render(); + + await userEvent.type(screen.getByLabelText(/stock symbol/i), 'AAPL'); + fireEvent.click(screen.getByRole('button', { name: /search/i })); + + expect(screen.getByRole('button')).toHaveTextContent('Loading...'); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + test('formats market cap in billions', async () => { + api.getStockPrice.mockResolvedValue({ + data: { + stock: { + symbol: 'MSFT', + price: 300.0, + previous_close: 299.0, + market_cap: 500000000, + currency: 'USD', + }, + }, + }); + + render(); + + await userEvent.type(screen.getByLabelText(/stock symbol/i), 'MSFT'); + fireEvent.click(screen.getByRole('button', { name: /search/i })); + + await waitFor(() => { + expect(screen.getByText('$500.00M')).toBeInTheDocument(); + }); + }); + + test('clears previous results on new search', async () => { + api.getStockPrice + .mockResolvedValueOnce({ + data: { + stock: { + symbol: 'AAPL', + price: 150.25, + previous_close: 149.5, + market_cap: 2500000000000, + currency: 'USD', + }, + }, + }) + .mockRejectedValueOnce({ + response: { data: { error: 'Not found' } }, + }); + + render(); + + const input = screen.getByLabelText(/stock symbol/i); + + // First search + await userEvent.type(input, 'AAPL'); + fireEvent.click(screen.getByRole('button', { name: /search/i })); + await waitFor(() => { + expect(screen.getByTestId('stock-result')).toBeInTheDocument(); + }); + + // Second search that fails + await userEvent.clear(input); + await userEvent.type(input, 'BAD'); + fireEvent.click(screen.getByRole('button', { name: /search/i })); + await waitFor(() => { + expect(screen.queryByTestId('stock-result')).not.toBeInTheDocument(); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 00000000..cedc382b --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,40 @@ +import axios from 'axios'; + +const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5050/api'; + +const api = axios.create({ + baseURL: API_BASE_URL, +}); + +api.interceptors.request.use((config) => { + const token = localStorage.getItem('access_token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response && error.response.status === 401) { + localStorage.removeItem('access_token'); + localStorage.removeItem('user'); + window.location.href = '/login'; + } + return Promise.reject(error); + } +); + +export const register = (username, email, password) => + api.post('/auth/register', { username, email, password }); + +export const login = (username, password) => + api.post('/auth/login', { username, password }); + +export const getCurrentUser = () => api.get('/auth/me'); + +export const getStockPrice = (symbol) => + api.get(`/stock/price?symbol=${encodeURIComponent(symbol)}`); + +export default api; diff --git a/frontend/src/components/Login.js b/frontend/src/components/Login.js new file mode 100644 index 00000000..4298c7c1 --- /dev/null +++ b/frontend/src/components/Login.js @@ -0,0 +1,66 @@ +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { login } from '../api'; + +function Login({ onLogin }) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const response = await login(username, password); + onLogin(response.data.user, response.data.access_token); + } catch (err) { + setError( + err.response?.data?.error || 'Login failed. Please try again.' + ); + } finally { + setLoading(false); + } + }; + + return ( +
+

Login

+ {error &&
{error}
} +
+
+ + setUsername(e.target.value)} + required + placeholder="Enter your username" + /> +
+
+ + setPassword(e.target.value)} + required + placeholder="Enter your password" + /> +
+ +
+

+ Don't have an account? Register here +

+
+ ); +} + +export default Login; diff --git a/frontend/src/components/Register.js b/frontend/src/components/Register.js new file mode 100644 index 00000000..f216f2f2 --- /dev/null +++ b/frontend/src/components/Register.js @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { register, login } from '../api'; + +function Register({ onLogin }) { + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + setLoading(true); + + try { + await register(username, email, password); + // Auto-login after registration + const loginResponse = await login(username, password); + onLogin(loginResponse.data.user, loginResponse.data.access_token); + } catch (err) { + setError( + err.response?.data?.error || 'Registration failed. Please try again.' + ); + } finally { + setLoading(false); + } + }; + + return ( +
+

Create Account

+ {error &&
{error}
} +
+
+ + setUsername(e.target.value)} + required + placeholder="Choose a username" + /> +
+
+ + setEmail(e.target.value)} + required + placeholder="Enter your email" + /> +
+
+ + setPassword(e.target.value)} + required + minLength={6} + placeholder="At least 6 characters" + /> +
+
+ + setConfirmPassword(e.target.value)} + required + placeholder="Confirm your password" + /> +
+ +
+

+ Already have an account? Login here +

+
+ ); +} + +export default Register; diff --git a/frontend/src/components/StockLookup.js b/frontend/src/components/StockLookup.js new file mode 100644 index 00000000..e6bf88df --- /dev/null +++ b/frontend/src/components/StockLookup.js @@ -0,0 +1,86 @@ +import React, { useState } from 'react'; +import { getStockPrice } from '../api'; + +function StockLookup() { + const [symbol, setSymbol] = useState(''); + const [stockData, setStockData] = useState(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSearch = async (e) => { + e.preventDefault(); + setError(''); + setStockData(null); + setLoading(true); + + try { + const response = await getStockPrice(symbol); + setStockData(response.data.stock); + } catch (err) { + setError( + err.response?.data?.error || 'Failed to fetch stock data. Please try again.' + ); + } finally { + setLoading(false); + } + }; + + const formatMarketCap = (value) => { + if (!value) return 'N/A'; + if (value >= 1e12) return `$${(value / 1e12).toFixed(2)}T`; + if (value >= 1e9) return `$${(value / 1e9).toFixed(2)}B`; + if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`; + return `$${value.toLocaleString()}`; + }; + + return ( +
+

Stock Price Lookup

+
+ setSymbol(e.target.value.toUpperCase())} + placeholder="Enter symbol (e.g., AAPL)" + required + maxLength={5} + aria-label="Stock symbol" + /> + +
+ + {error &&
{error}
} + + {stockData && ( +
+

{stockData.symbol}

+
+ {stockData.price ? `$${stockData.price.toFixed(2)}` : 'N/A'} +
+
+
+ Previous Close + + {stockData.previous_close ? `$${stockData.previous_close.toFixed(2)}` : 'N/A'} + +
+
+ Market Cap + + {formatMarketCap(stockData.market_cap)} + +
+
+ Currency + {stockData.currency} +
+
+
+ )} +
+ ); +} + +export default StockLookup; diff --git a/frontend/src/index.js b/frontend/src/index.js new file mode 100644 index 00000000..593edf12 --- /dev/null +++ b/frontend/src/index.js @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render( + + + +); diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js new file mode 100644 index 00000000..7b0828bf --- /dev/null +++ b/frontend/src/setupTests.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom';