diff --git a/.github/workflows/E2E.yml b/.github/workflows/E2E.yml index be21126..82815e3 100644 --- a/.github/workflows/E2E.yml +++ b/.github/workflows/E2E.yml @@ -10,6 +10,8 @@ jobs: runs-on: ubuntu-latest env: SPOONACULAR_API_KEY: ${{ secrets.SPOONACULAR_API_KEY }} + VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL}} + VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY}} steps: - name: Checkout code @@ -62,3 +64,9 @@ jobs: - name: Run Cypress tests run: cd frontend && npm run cy:run + + - name: Upload Cypress screenshots + uses: actions/upload-artifact@v4 + with: + name: cypress-screenshots + path: frontend/cypress/screenshots \ No newline at end of file diff --git a/.github/workflows/arihant.yml b/.github/workflows/arihant.yml new file mode 100644 index 0000000..b99d2dd --- /dev/null +++ b/.github/workflows/arihant.yml @@ -0,0 +1,148 @@ +name: All Combined Tests + +on: + pull_request: + branches: + - main + +jobs: + # Backend tests with pytest (FastAPI) + test-backend: + runs-on: ubuntu-latest + + env: + SPOONACULAR_API_KEY: ${{ secrets.SPOONACULAR_API_KEY }} + VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }} + VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.9" + + - name: Install backend dependencies + run: | + cd backend + python -m venv venv + source venv/bin/activate + pip install -r requirements.txt + pip install pytest + + - name: Debug Environment + run: | + cd backend + source venv/bin/activate + echo "API Key presence check:" + echo "$SPOONACULAR_API_KEY" + echo "Python environment:" + which python + python --version + + - name: Run pytest tests + run: | + cd backend + source venv/bin/activate + export SPOONACULAR_API_KEY=${{ secrets.SPOONACULAR_API_KEY }} + python -m pytest -v + + # Frontend tests with Vitest + test-frontend: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Clean install dependencies + run: | + cd frontend + rm -rf node_modules package-lock.json + npm install + npm install @rollup/rollup-linux-x64-gnu + + - name: Create .env file + run: | + cd frontend + echo "VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}" >> .env + echo "VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}" >> .env + + - name: Run Vitest tests + run: | + cd frontend + npm run test -- --run + + # E2E Tests + e2e: + runs-on: ubuntu-latest + env: + SPOONACULAR_API_KEY: ${{ secrets.SPOONACULAR_API_KEY }} + VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }} + VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }} + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: "20" + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.9" + + - name: Clean install dependencies + run: | + cd frontend + rm -rf node_modules package-lock.json + npm install + npm install @rollup/rollup-linux-x64-gnu + + - name: Install backend dependencies + run: | + cd backend + python -m venv venv + source venv/bin/activate + pip install -r requirements.txt + + - name: Create .env file for frontend + run: | + cd frontend + echo "VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}" >> .env + echo "VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}" >> .env + + - name: Build the project + run: cd frontend && npm run build + + - name: Start preview server + run: cd frontend && npm run preview & + + - name: Start backend server + run: | + cd backend + source venv/bin/activate + python3 -m fastapi dev main.py & + + - name: Wait for server to be ready + run: npx wait-on http://localhost:4173 + + - name: Run Cypress tests + run: cd frontend && npm run cy:run + continue-on-error: true + + - name: Upload Cypress screenshots + uses: actions/upload-artifact@v4 + with: + name: cypress-screenshots + path: frontend/cypress/screenshots diff --git a/backend/coverage_report.txt b/backend/coverage_report.txt new file mode 100644 index 0000000..0778fab --- /dev/null +++ b/backend/coverage_report.txt @@ -0,0 +1,27 @@ +CODE COVERAGE BEFORE ADDING GEMINI TESTING + +Name Stmts Miss Cover +---------------------------------------- +main.py 19 4 79% +routers\gemini.py 41 23 44% +routers\recipes.py 26 9 65% +routers\user.py 40 5 88% +test_recipes.py 16 0 100% +test_user.py 65 1 98% +---------------------------------------- +TOTAL 207 42 80% + +--------------------------------------------- +AFTER ADDING GEMINI TESTING + +Name Stmts Miss Cover +------------------------------------------ +main.py 19 4 79% +routers\gemini.py 41 7 83% +routers\recipes.py 26 9 65% +routers\user.py 40 5 88% +test_gemini_extra.py 56 0 100% +test_recipes.py 16 0 100% +test_user.py 65 1 98% +------------------------------------------ +TOTAL 263 26 90% \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index f15b6f5..3a87ffc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,4 +6,5 @@ httpx google-generativeai Pillow supabase -pytest \ No newline at end of file +pytest +coverage \ No newline at end of file diff --git a/backend/routers/user.py b/backend/routers/user.py index 445a52a..3b999f5 100644 --- a/backend/routers/user.py +++ b/backend/routers/user.py @@ -35,6 +35,12 @@ async def get_user_favorite(request: Request) -> Any: .single() \ .execute() + if not resp or not resp.data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Supabase query failed" + ) + # 3) pull the JSON array of recipes recipes: List[Any] = resp.data.get("favorite") or [] @@ -59,8 +65,9 @@ async def set_user_favorite(request: Request, recipe: Dict[str, Any] = Body(...) .eq("UID", user_id) \ .single() \ .execute() - - raw = resp.data.get("favorite") + + raw = resp.data.get("favorite") if resp and resp.data else None + # 2) coerce to a list if raw is None: current: List[Any] = [] @@ -70,9 +77,11 @@ async def set_user_favorite(request: Request, recipe: Dict[str, Any] = Body(...) # if it's a dict (or anything else), wrap it into a one-element list current = [raw] - # 3) append the new recipe object - updated = current + [recipe] - + # 3) check for duplicates and append the new recipe object + if recipe not in current: + updated = current + [recipe] + else: + updated = current upd = sb.from_("user") \ .update({"favorite": updated}) \ diff --git a/backend/test_gemini.py b/backend/test_gemini.py new file mode 100644 index 0000000..4c2040e --- /dev/null +++ b/backend/test_gemini.py @@ -0,0 +1,65 @@ +import pytest +from fastapi import HTTPException +from routers import gemini +import types +import asyncio + +class DummyResponse: + def __init__(self, text, status_code=200): + self.text = text + self.status_code = status_code + def json(self): + return [{"id": 1, "title": "Dummy Recipe"}] + + +def test_get_ingredients_from_image_success(monkeypatch): + class DummyModel: + def generate_content(self, args): + class Resp: + text = "tomato, cheese" + return Resp() + monkeypatch.setattr(gemini, "model", DummyModel()) + # Moc PIL.Image.open to avoid real image + monkeypatch.setattr(gemini.Image, "open", lambda x: object()) + # PNG header + dummy data + image_bytes = b"\x89PNG\r\n\x1a\n" + b"0" * 100 + result = asyncio.run(gemini.get_ingredients_from_image(image_bytes)) + assert result == ["tomato", "cheese"] + +def test_get_ingredients_from_image_error(monkeypatch): + class DummyModel: + def generate_content(self, args): + raise Exception("Gemini error") + monkeypatch.setattr(gemini, "model", DummyModel()) + monkeypatch.setattr(gemini.Image, "open", lambda x: object()) + image_bytes = b"notanimage" + with pytest.raises(HTTPException) as exc: + asyncio.run(gemini.get_ingredients_from_image(image_bytes)) + assert "Error analyzing image" in str(exc.value.detail) + +def test_get_recipes_from_ingredients_success(monkeypatch): + def dummy_get(url, params): + return DummyResponse("", 200) + monkeypatch.setattr(gemini.requests, "get", dummy_get) + result = asyncio.run(gemini.get_recipes_from_ingredients(["tomato", "cheese"])) + assert result[0]["title"] == "Dummy Recipe" + +def test_get_recipes_from_ingredients_api_error(monkeypatch): + def dummy_get(url, params): + return DummyResponse("", 500) + monkeypatch.setattr(gemini.requests, "get", dummy_get) + with pytest.raises(HTTPException) as exc: + asyncio.run(gemini.get_recipes_from_ingredients(["tomato"])) + assert "Error fetching recipes" in str(exc.value.detail) + +def test_get_ingredients_from_image_empty(monkeypatch): + class DummyModel: + def generate_content(self, args): + class Resp: + text = " " + return Resp() + monkeypatch.setattr(gemini, "model", DummyModel()) + monkeypatch.setattr(gemini.Image, "open", lambda x: object()) + image_bytes = b"\x89PNG\r\n\x1a\n" + b"0" * 100 + result = asyncio.run(gemini.get_ingredients_from_image(image_bytes)) + assert result == [""] diff --git a/backend/test_user.py b/backend/test_user.py index a0f2d35..923138a 100644 --- a/backend/test_user.py +++ b/backend/test_user.py @@ -87,3 +87,54 @@ def test_post_appends_to_list(stub_supabase): favs = r.json()["favorites"] assert isinstance(favs, list) assert existing in favs and new_recipe in favs + +# POST /user/favorite and GET /user/favorite Integration Test + +def test_post_and_get_favorite(stub_supabase): + stub_supabase.next_resp = FakeResp({"favorite": []}) + new_recipe = {"id": 99, "title": "IntegrationTestRecipe"} + + r_post = client.post( + "/user/favorite", + headers={"ID": TEST_USER_ID}, + json=new_recipe + ) + assert r_post.status_code == 200 + favs = r_post.json()["favorites"] + assert new_recipe in favs + + stub_supabase.next_resp = FakeResp({"favorite": [new_recipe]}) + r_get = client.get("/user/favorite", headers={"ID": TEST_USER_ID}) + assert r_get.status_code == 200 + body = r_get.json() + assert body["user_id"] == TEST_USER_ID + assert new_recipe in body["recipes"] + +# Test for error condition: Simulating a Supabase query failure +def test_supabase_query_failure(stub_supabase): + class ErrorResp: + def __init__(self): + self.status_code = 500 + self.data = None + def execute(self): + raise Exception("Supabase query failed") + + stub_supabase.next_resp = ErrorResp() + + r = client.get("/user/favorite", headers={"ID": TEST_USER_ID}) + assert r.status_code == 400 + assert "Supabase query failed" in r.json()["detail"] + +# Test for expected interaction: Preventing duplicate recipes +def test_prevent_duplicate_recipes(stub_supabase): + existing_recipe = {"id": 1, "title": "DuplicateTestRecipe"} + stub_supabase.next_resp = FakeResp({"favorite": [existing_recipe]}) + + r = client.post( + "/user/favorite", + headers={"ID": TEST_USER_ID}, + json=existing_recipe + ) + assert r.status_code == 200 + favs = r.json()["favorites"] + assert favs == [existing_recipe] \ No newline at end of file diff --git a/frontend/cypress.config.ts b/frontend/cypress.config.ts index 6feaf18..46ffdf4 100644 --- a/frontend/cypress.config.ts +++ b/frontend/cypress.config.ts @@ -3,5 +3,6 @@ const { defineConfig } = require('cypress'); module.exports = defineConfig({ e2e: { baseUrl: 'http://localhost:4173', + screenshotOnRunFailure: true, }, }); \ No newline at end of file diff --git a/frontend/cypress/e2e/Auth.cy.ts b/frontend/cypress/e2e/Auth.cy.ts new file mode 100644 index 0000000..0f0569c --- /dev/null +++ b/frontend/cypress/e2e/Auth.cy.ts @@ -0,0 +1,34 @@ +/// + +describe("Signup Page", () => { + it("should allow a new user to sign up", () => { + cy.visit("http://localhost:4173/signup"); + + cy.get("input#name").type("Test User"); + cy.get("input#email").type(`testuser_${Date.now()}@gmail.com`); + cy.get("input#password").type("TestPassword123!"); + + cy.get('button[type="submit"]').click(); + + cy.on("window:alert", (txt) => { + expect(txt.toLowerCase()).to.contain("signup successful"); + }); + + cy.url({ timeout: 10000 }).should("include", "/login"); + }); +}); + +describe("Login Page", () => { + it("should allow an existing user to log in", () => { + cy.visit("http://localhost:4173/login"); + + cy.get("input#email").type("Arihanttiwari2005@gmail.com"); + cy.get("input#password").type("Testing123@"); + + cy.get('button[type="submit"]').click(); + + cy.contains(/welcome|dashboard|recipes|logout/i, { timeout: 10000 }).should( + "exist" + ); + }); +}); diff --git a/frontend/cypress/e2e/recipes.cy.ts b/frontend/cypress/e2e/recipes.cy.ts new file mode 100644 index 0000000..fa22f6a --- /dev/null +++ b/frontend/cypress/e2e/recipes.cy.ts @@ -0,0 +1,8 @@ +/// + +describe('Recipes Page', () => { + it('should display exactly 20 recipes', () => { + cy.visit('http://localhost:4173/recipes'); + cy.get('[data-cy=recipe-card]').should('have.length', 20); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/Recipe.tsx b/frontend/src/components/Recipe.tsx index 6975979..c88e489 100644 --- a/frontend/src/components/Recipe.tsx +++ b/frontend/src/components/Recipe.tsx @@ -14,7 +14,7 @@ interface RecipeProps { vegan: boolean; vegetarian: boolean; }; - initialFavorited?: boolean; // ← new + initialFavorited?: boolean; // ← new } function Recipe({ recipe, initialFavorited = false }: RecipeProps) { @@ -59,7 +59,10 @@ function Recipe({ recipe, initialFavorited = false }: RecipeProps) { }; return ( -
+
{/* Favorite button */}