diff --git a/backend/src/main/java/com/group1/cuisines/controllers/UserController.java b/backend/src/main/java/com/group1/cuisines/controllers/UserController.java index 0f17a251..f1f42ee2 100644 --- a/backend/src/main/java/com/group1/cuisines/controllers/UserController.java +++ b/backend/src/main/java/com/group1/cuisines/controllers/UserController.java @@ -66,7 +66,7 @@ public ResponseEntity getUserDetails(@AuthenticationPrincipal UserDetails use return ResponseEntity.ok(new SuccessResponse<>(200, userProfile, "User profile fetched successfully")); } } - return ResponseEntity.ok(new ErrorResponse(204, "User not found")); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse(401,"Authentication required") ); } @DeleteMapping("/{userId}/follow") diff --git a/backend/src/main/java/com/group1/cuisines/dto/UserUpdateFormDto.java b/backend/src/main/java/com/group1/cuisines/dto/UserUpdateFormDto.java index 5f0d94a7..b1c95a7a 100644 --- a/backend/src/main/java/com/group1/cuisines/dto/UserUpdateFormDto.java +++ b/backend/src/main/java/com/group1/cuisines/dto/UserUpdateFormDto.java @@ -11,8 +11,7 @@ @AllArgsConstructor public class UserUpdateFormDto { private String username; - private String firstName; - private String lastName; + private String name; private String bio; private String gender; private String profilePicture; diff --git a/backend/src/main/java/com/group1/cuisines/entities/Bookmark.java b/backend/src/main/java/com/group1/cuisines/entities/Bookmark.java index 0f0d806d..bf2c5c0d 100644 --- a/backend/src/main/java/com/group1/cuisines/entities/Bookmark.java +++ b/backend/src/main/java/com/group1/cuisines/entities/Bookmark.java @@ -2,6 +2,8 @@ import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import java.util.Objects; @@ -19,10 +21,12 @@ public class Bookmark { private Integer id; @ManyToOne + @OnDelete(action = OnDeleteAction.CASCADE) @JoinColumn(name = "user_id", nullable = false) private User user; @ManyToOne + @OnDelete(action = OnDeleteAction.CASCADE) @JoinColumn(name = "recipe_id", nullable = false) private Recipe recipe; diff --git a/backend/src/main/java/com/group1/cuisines/entities/Comment.java b/backend/src/main/java/com/group1/cuisines/entities/Comment.java index 3205558c..91ca34f1 100644 --- a/backend/src/main/java/com/group1/cuisines/entities/Comment.java +++ b/backend/src/main/java/com/group1/cuisines/entities/Comment.java @@ -3,6 +3,8 @@ import jakarta.persistence.*; import lombok.*; import org.apache.commons.lang3.builder.ToStringExclude; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import java.time.LocalDateTime; import java.util.Date; @@ -23,11 +25,13 @@ public class Comment { @ManyToOne + @OnDelete(action = OnDeleteAction.CASCADE) @JoinColumn(name = "user_id", nullable = false) @ToString.Exclude private User user; @ManyToOne + @OnDelete(action = OnDeleteAction.CASCADE) @JoinColumn(name = "recipe_id", nullable = false) private Recipe recipe; diff --git a/backend/src/main/java/com/group1/cuisines/entities/Ingredient.java b/backend/src/main/java/com/group1/cuisines/entities/Ingredient.java index be53c193..f3886ab5 100644 --- a/backend/src/main/java/com/group1/cuisines/entities/Ingredient.java +++ b/backend/src/main/java/com/group1/cuisines/entities/Ingredient.java @@ -2,6 +2,8 @@ import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import java.util.Objects; @@ -19,6 +21,7 @@ public class Ingredient { private String name; private String amount; // E.g. grams, cups, etc. @ManyToOne + @OnDelete(action = OnDeleteAction.CASCADE) @JoinColumn(name = "recipe_id") // This column in the Ingredient table will hold the foreign key to the Recipe private Recipe recipe; // This is the 'recipe' field expected by the 'mappedBy' attribute @Override diff --git a/backend/src/main/java/com/group1/cuisines/entities/Rating.java b/backend/src/main/java/com/group1/cuisines/entities/Rating.java index ad64aaa0..ef476af6 100644 --- a/backend/src/main/java/com/group1/cuisines/entities/Rating.java +++ b/backend/src/main/java/com/group1/cuisines/entities/Rating.java @@ -2,6 +2,8 @@ import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import java.util.Objects; @@ -18,10 +20,12 @@ public class Rating { private Integer id; @ManyToOne + @OnDelete(action = OnDeleteAction.CASCADE) @JoinColumn(name = "user_id", nullable = false) private User user; @ManyToOne + @OnDelete(action = OnDeleteAction.CASCADE) @JoinColumn(name = "recipe_id", nullable = false) private Recipe recipe; diff --git a/backend/src/main/java/com/group1/cuisines/entities/Recipe.java b/backend/src/main/java/com/group1/cuisines/entities/Recipe.java index 95921787..8a545784 100644 --- a/backend/src/main/java/com/group1/cuisines/entities/Recipe.java +++ b/backend/src/main/java/com/group1/cuisines/entities/Recipe.java @@ -20,7 +20,9 @@ public class Recipe { @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String name; + @Column(length = 5000) private String description; + @Column(length = 5000) private String instructions; private int prepTime; private int cookTime; diff --git a/backend/src/main/java/com/group1/cuisines/entities/Upvote.java b/backend/src/main/java/com/group1/cuisines/entities/Upvote.java index c1be106b..35eab261 100644 --- a/backend/src/main/java/com/group1/cuisines/entities/Upvote.java +++ b/backend/src/main/java/com/group1/cuisines/entities/Upvote.java @@ -2,6 +2,8 @@ import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import java.time.LocalDateTime; import java.util.Objects; @@ -19,11 +21,13 @@ public class Upvote { private Integer id; @ManyToOne + @OnDelete(action = OnDeleteAction.CASCADE) @JoinColumn(name = "user_id", nullable = false) private User user; private LocalDateTime createdDate = LocalDateTime.now(); @ManyToOne + @OnDelete(action = OnDeleteAction.CASCADE) @JoinColumn(name = "comment_id", nullable = false) private Comment comment; @Override diff --git a/backend/src/main/java/com/group1/cuisines/services/UserService.java b/backend/src/main/java/com/group1/cuisines/services/UserService.java index 956abb8e..2d04d082 100644 --- a/backend/src/main/java/com/group1/cuisines/services/UserService.java +++ b/backend/src/main/java/com/group1/cuisines/services/UserService.java @@ -13,6 +13,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Set; @@ -148,8 +149,9 @@ public UserProfileDto updateUserProfile(Integer userId, UserUpdateFormDto profil User user = userRepository.findById(userId) .orElseThrow(() -> new EntityNotFoundException("User not found")); - user.setFirstName(profileDto.getFirstName()); - user.setLastName(profileDto.getLastName()); + ArrayList names = new ArrayList<>(List.of(profileDto.getName().split(" "))); + user.setLastName(names.remove(names.size() - 1)); + user.setFirstName(String.join(" ", names)); user.setBio(profileDto.getBio()); user.setGender(profileDto.getGender()); user.setProfilePicture(profileDto.getProfilePicture()); diff --git a/backend/src/test/java/com/group1/cuisines/DishControllerTest.java b/backend/src/test/java/com/group1/cuisines/DishControllerTest.java new file mode 100644 index 00000000..5d313965 --- /dev/null +++ b/backend/src/test/java/com/group1/cuisines/DishControllerTest.java @@ -0,0 +1,67 @@ +package com.group1.cuisines; + +import com.group1.cuisines.controllers.DishController; +import com.group1.cuisines.dao.response.ErrorResponse; +import com.group1.cuisines.dao.response.SuccessResponse; +import com.group1.cuisines.dto.DishDto; +import com.group1.cuisines.services.DishService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DishControllerTest { + + @InjectMocks + private DishController dishController; + + @Mock + private DishService dishService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void testGetDishById_Found() { + String dishId = "test-id"; + DishDto mockDish = DishDto.builder() + .id(dishId) + .name("Test Dish") + .description("Delicious dish") + .image("./image.jpg") + .countries("USA") + .ingredients("Ingredients") + .foodTypes("Type") + .cuisines("Cuisine") + .build(); + + when(dishService.getDishById(dishId)).thenReturn(mockDish); + + SuccessResponse response = (SuccessResponse) dishController.getDishById(dishId).getBody(); + + DishDto actualDish = response.getData(); + + assertEquals(200, response.getStatus()); + assertEquals(mockDish, actualDish); + } + + @Test + public void testGetDishById_NotFound() { + String dishId = "test-id"; + when(dishService.getDishById(dishId)).thenReturn(null); + + ErrorResponse response = (ErrorResponse) dishController.getDishById(dishId).getBody(); + + assertEquals(400, response.getStatus()); + } + +} diff --git a/backend/src/test/java/com/group1/cuisines/UserControllerTest.java b/backend/src/test/java/com/group1/cuisines/UserControllerTest.java index d52333b9..219c77bf 100644 --- a/backend/src/test/java/com/group1/cuisines/UserControllerTest.java +++ b/backend/src/test/java/com/group1/cuisines/UserControllerTest.java @@ -104,7 +104,7 @@ void testGetUserDetails_UserNotFound() { ResponseEntity response = userController.getUserDetails(userDetails); - assertEquals(204, ((ErrorResponse) response.getBody()).getStatus()); + assertEquals(401, ((ErrorResponse) response.getBody()).getStatus()); verify(userRepository).findByUsername(username); } diff --git a/frontend/package.json b/frontend/package.json index 1e7bc8c2..b5e8a695 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -82,7 +82,6 @@ "lint-staged": { "*/**/*.{js,jsx,ts,tsx}": [ "prettier --write", - "eslint --report-unused-disable-directives --max-warnings 0" ], "*/**/*.{json,css,md}": [ diff --git a/frontend/src/components/BookmarkButton.test.tsx b/frontend/src/components/BookmarkButton.test.tsx new file mode 100644 index 00000000..3b076adc --- /dev/null +++ b/frontend/src/components/BookmarkButton.test.tsx @@ -0,0 +1,168 @@ +import { describe, it, beforeEach, expect, vi, Mock } from "vitest"; +import { + render, + screen, + fireEvent, + waitFor, + act, +} from "@testing-library/react"; +import { + useGetMe, + useGetBookmarkers, + useGetRecipeById, + useBookmarkRecipe, + useUnbookmarkRecipe, +} from "@/services/api/semanticBrowseComponents"; +import BookmarkButton from "./BookmarkButton"; + +vi.mock("@/services/api/semanticBrowseComponents", () => ({ + useGetMe: vi.fn(), + useGetBookmarkers: vi.fn(), + useGetRecipeById: vi.fn(), + useBookmarkRecipe: vi + .fn() + .mockReturnValue({ mutateAsync: vi.fn().mockResolvedValue({}) }), + useUnbookmarkRecipe: vi + .fn() + .mockReturnValue({ mutateAsync: vi.fn().mockResolvedValue({}) }), +})); + +describe("BookmarkButton", () => { + const refetch = vi.fn().mockResolvedValue(true); + let bookmarked = false; + let shouldSucceed = true; + const unbookmarkMock = vi.fn(() => + shouldSucceed + ? ((bookmarked = false), Promise.resolve()) + : Promise.reject(), + ); + const bookmarkMock = vi.fn(() => + shouldSucceed ? ((bookmarked = true), Promise.resolve()) : Promise.reject(), + ); + + beforeEach(() => { + bookmarked = false; + + (useGetRecipeById as Mock).mockImplementation(() => ({ + isLoading: false, + data: { data: { selfBookmarked: bookmarked } }, + error: null, + refetch, + })); + + (useBookmarkRecipe as Mock).mockImplementation( + ({ onSuccess, onError } = {}) => ({ + mutateAsync: (...args: unknown[]) => + // @ts-expect-error we don't care about the args + bookmarkMock(...args).then(onSuccess, onError), + }), + ); + (useUnbookmarkRecipe as Mock).mockImplementation( + ({ onSuccess, onError } = {}) => ({ + mutateAsync: (...args: unknown[]) => + // @ts-expect-error we don't care about the args + unbookmarkMock(...args).then(onSuccess, onError), + }), + ); + (useGetMe as Mock).mockReturnValue({ + refetch: vi.fn().mockResolvedValue(true), + }); + (useGetBookmarkers as Mock).mockReturnValue({ + refetch: vi.fn().mockResolvedValue(true), + }); + }); + + it("should render the bookmark button correctly", () => { + render(); + expect(screen.getByText("Bookmark")).toBeInTheDocument(); + }); + + describe("when not bookmarked", () => { + it("should trigger bookmark and refetch on click when successful", async () => { + bookmarked = false; + shouldSucceed = true; + + render(); + + act(() => { + fireEvent.click(screen.getByText("Bookmark")); + }); + + await waitFor(() => { + expect(bookmarkMock).toHaveBeenCalledWith({ + pathParams: { recipeId: 1 }, + }); + }); + + await waitFor(() => { + expect((useGetRecipeById as Mock)().refetch).toHaveBeenCalled(); + expect((useGetMe as Mock)().refetch).toHaveBeenCalled(); + expect((useGetBookmarkers as Mock)().refetch).toHaveBeenCalled(); + expect(unbookmarkMock).not.toHaveBeenCalled(); + expect(screen.getByText("Bookmarked")).toBeInTheDocument(); + }); + }); + + it("should trigger bookmark and return to unbookmarked on error", async () => { + bookmarked = false; + shouldSucceed = false; + + render(); + + act(() => { + fireEvent.click(screen.getByText("Bookmark")); + }); + + await waitFor(() => { + expect(bookmarkMock).toHaveBeenCalledWith({ + pathParams: { recipeId: 1 }, + }); + }); + + await waitFor(() => { + expect(unbookmarkMock).not.toHaveBeenCalled(); + expect(screen.getByText("Bookmark")).toBeInTheDocument(); + }); + }); + }); + + describe("when bookmarked", () => { + it("should trigger unbookmark and refetch on click when bookmarked", async () => { + bookmarked = true; + shouldSucceed = true; + + render(); + act(() => { + fireEvent.click(screen.getByText("Bookmarked")); + }); + + await waitFor(() => { + expect(unbookmarkMock).toHaveBeenCalledWith({ + pathParams: { recipeId: 1 }, + }); + expect((useGetRecipeById as Mock)().refetch).toHaveBeenCalled(); + expect((useGetMe as Mock)().refetch).toHaveBeenCalled(); + expect((useGetBookmarkers as Mock)().refetch).toHaveBeenCalled(); + expect(screen.getByText("Bookmark")).toBeInTheDocument(); + }); + }); + + it("should trigger unbookmark and still show bookmarked on error", async () => { + bookmarked = true; + shouldSucceed = false; + + render(); + act(() => { + fireEvent.click(screen.getByText("Bookmarked")); + }); + + expect(unbookmarkMock).toHaveBeenCalledWith({ + pathParams: { recipeId: 1 }, + }); + + await waitFor(() => { + expect(screen.getByText("Bookmarked")).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/frontend/src/components/BookmarkButton.tsx b/frontend/src/components/BookmarkButton.tsx index d04c2017..2e25d0c2 100644 --- a/frontend/src/components/BookmarkButton.tsx +++ b/frontend/src/components/BookmarkButton.tsx @@ -18,22 +18,17 @@ export default function BookmarkButton({ asIcon?: boolean; }) { const { refetch: refetchMe } = useGetMe({}); - console.log(recipe); + const { refetch: refetchBookmarkers } = useGetBookmarkers({ pathParams: { recipeId: recipe.id!, }, }); - const { isLoading, data, error, refetch } = useGetRecipeById( - { - pathParams: { - recipeId: recipe.id!, - }, - }, - { - enabled: typeof recipe.selfBookmarked !== "boolean", + const { isLoading, data, error, refetch } = useGetRecipeById({ + pathParams: { + recipeId: recipe.id!, }, - ); + }); const [optimisticBookmarked, setOptimisticBookmarked] = useState( null as boolean | null, @@ -41,11 +36,14 @@ export default function BookmarkButton({ const { mutateAsync: bookmark } = useBookmarkRecipe({ onSuccess: () => { - refetch().then(() => { - setOptimisticBookmarked(null); - refetchMe(); - refetchBookmarkers(); - }); + return refetch() + .then(() => { + refetchMe(); + refetchBookmarkers(); + }) + .finally(() => { + setOptimisticBookmarked(null); + }); }, onError: () => { setOptimisticBookmarked(null); @@ -53,20 +51,21 @@ export default function BookmarkButton({ }); const { mutateAsync: unbookmark } = useUnbookmarkRecipe({ onSuccess: () => { - refetch().then(() => { - setOptimisticBookmarked(null); - refetchMe(); - refetchBookmarkers(); - }); + refetch() + .then(() => { + refetchMe(); + refetchBookmarkers(); + }) + .finally(() => { + setOptimisticBookmarked(null); + }); }, onError: () => { setOptimisticBookmarked(null); }, }); - const bookmarked = - optimisticBookmarked ?? recipe.selfBookmarked ?? data?.data?.selfBookmarked; - console.log(recipe.id, bookmarked); + const bookmarked = optimisticBookmarked ?? data?.data?.selfBookmarked; const variant = bookmarked && !isLoading ? "primary-outline" : "default"; diff --git a/frontend/src/components/Bookmarkers.test.tsx b/frontend/src/components/Bookmarkers.test.tsx new file mode 100644 index 00000000..44f0178c --- /dev/null +++ b/frontend/src/components/Bookmarkers.test.tsx @@ -0,0 +1,78 @@ +import { render, fireEvent, screen } from "@testing-library/react"; +import { vi, test, Mock, expect, describe, beforeEach } from "vitest"; +import { Bookmarkers } from "./Bookmarkers"; +import { MemoryRouter } from "react-router-dom"; +import { useGetBookmarkers } from "@/services/api/semanticBrowseComponents"; + +// Mocking the useGetBookmarkers hook +vi.mock("@/services/api/semanticBrowseComponents", () => ({ + useGetBookmarkers: vi.fn().mockReturnValue({ + data: null, + isLoading: false, + error: true, + }), + useFollowUser: vi.fn().mockReturnValue({ + data: null, + isLoading: false, + error: true, + }), + useGetUserById: vi.fn().mockReturnValue({ + data: null, + isLoading: false, + error: true, + }), + useUnfollowUser: vi.fn().mockReturnValue({ + data: null, + isLoading: false, + error: true, + }), +})); + +describe("Bookmarkers", () => { + let recipeId: number; + + beforeEach(() => { + recipeId = 1; + vi.clearAllMocks(); + }); + + test("displays error state when there is an error", () => { + (useGetBookmarkers as Mock).mockReturnValue({ + data: null, + isLoading: false, + error: true, + }); + + render(); + expect(screen.getByText(/Error/i)).toBeInTheDocument(); + }); + + test("checks if bookmarkers button works on click", () => { + (useGetBookmarkers as Mock).mockReturnValue({ + data: { data: [{ id: 1 }, { id: 2 }] }, + isLoading: false, + error: null, + }); + render( + + + , + ); + + // Act + const bookmarkersButton = screen + .getByText(/see bookmarkers/i) + .closest("span"); + expect(bookmarkersButton).toBeInTheDocument(); + fireEvent.click(bookmarkersButton!); // Open the popover + expect( + screen.getByRole("heading", { name: "Bookmarkers" }), + ).toBeInTheDocument(); + + const popoverCloseButton = screen.getByRole("button", { name: /close/i }); + fireEvent.click(popoverCloseButton); // Close the popover without confirming + expect( + screen.queryByRole("heading", { name: "Bookmarkers" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/InstructionsInput.tsx b/frontend/src/components/InstructionsInput.tsx index 69df1e23..1e4a332a 100644 --- a/frontend/src/components/InstructionsInput.tsx +++ b/frontend/src/components/InstructionsInput.tsx @@ -4,10 +4,11 @@ import { useFormContext } from "react-hook-form"; import { Button } from "./ui/button"; import { PlusIcon } from "lucide-react"; import { Textarea } from "./ui/textarea"; +import { FormField, FormMessage } from "./ui/form"; export default function InstructionsInput() { const [count, setCount] = useState(1); - const { register, setValue, getValues } = useFormContext(); + const { register, setValue, getValues, control } = useFormContext(); const deleteIndex = (index: number) => { setCount(count - 1); @@ -36,6 +37,11 @@ export default function InstructionsInput() { ))} + } + />