diff --git a/backend/src/main/java/com/greenfoxacademy/backend/config/SecurityConfig.java b/backend/src/main/java/com/greenfoxacademy/backend/config/SecurityConfig.java index 98aede3d..7dae77e7 100644 --- a/backend/src/main/java/com/greenfoxacademy/backend/config/SecurityConfig.java +++ b/backend/src/main/java/com/greenfoxacademy/backend/config/SecurityConfig.java @@ -59,6 +59,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(allowedUrls).permitAll() .requestMatchers("/profile-update").authenticated() .requestMatchers("/pets").authenticated() + .requestMatchers("/add-pet").authenticated() .anyRequest().authenticated() ) .cors(cors -> cors.configurationSource(corsConfigurationSource())) diff --git a/backend/src/main/java/com/greenfoxacademy/backend/controller/OwnerController.java b/backend/src/main/java/com/greenfoxacademy/backend/controller/OwnerController.java index fe3890e1..7766107a 100644 --- a/backend/src/main/java/com/greenfoxacademy/backend/controller/OwnerController.java +++ b/backend/src/main/java/com/greenfoxacademy/backend/controller/OwnerController.java @@ -13,7 +13,6 @@ import com.greenfoxacademy.backend.services.user.OwnerService; import java.security.Principal; import java.util.UUID; - import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; diff --git a/backend/src/main/java/com/greenfoxacademy/backend/controller/PetController.java b/backend/src/main/java/com/greenfoxacademy/backend/controller/PetController.java index b8d817a9..c98ea519 100644 --- a/backend/src/main/java/com/greenfoxacademy/backend/controller/PetController.java +++ b/backend/src/main/java/com/greenfoxacademy/backend/controller/PetController.java @@ -1,5 +1,7 @@ package com.greenfoxacademy.backend.controller; +import com.greenfoxacademy.backend.dtos.AddPetResponseDto; +import com.greenfoxacademy.backend.dtos.CreatePetDto; import com.greenfoxacademy.backend.dtos.PetListResponseDto; import com.greenfoxacademy.backend.services.pet.PetService; import java.security.Principal; @@ -7,6 +9,8 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; /** @@ -19,8 +23,30 @@ public class PetController { private final PetService petService; + /** + * Retrieves the list of pets owned by the authenticated user. + * + * @param owner the authenticated user's principal + * @return a {@link ResponseEntity} containing a {@link PetListResponseDto} with the list of pets + */ @GetMapping("/pets") public ResponseEntity getPets(Principal owner) { return ResponseEntity.status(HttpStatus.OK).body(petService.getOwnerPets(owner.getName())); } + + /** + * Adds a new pet for the authenticated user. + * + * @param owner the authenticated user's principal + * @param createPetDto the details of the pet to be added + * @return a {@link ResponseEntity} containing an {@link AddPetResponseDto} + with the added pet's details + */ + @PostMapping("/add-pet") + public ResponseEntity addPet(Principal owner, + @RequestBody CreatePetDto createPetDto) { + return ResponseEntity + .status(HttpStatus.OK) + .body(petService.addPet(owner.getName(), createPetDto)); + } } diff --git a/backend/src/main/java/com/greenfoxacademy/backend/dtos/AddPetResponseDto.java b/backend/src/main/java/com/greenfoxacademy/backend/dtos/AddPetResponseDto.java new file mode 100644 index 00000000..4a65db2f --- /dev/null +++ b/backend/src/main/java/com/greenfoxacademy/backend/dtos/AddPetResponseDto.java @@ -0,0 +1,12 @@ +package com.greenfoxacademy.backend.dtos; + +/** + * A Data Transfer Object (DTO) for the response after adding a new pet. + * Contains the ID of the newly added pet. + * + * @param id the ID of the newly added pet + */ +public record AddPetResponseDto( + Integer id +) { +} diff --git a/backend/src/main/java/com/greenfoxacademy/backend/dtos/CreatePetDto.java b/backend/src/main/java/com/greenfoxacademy/backend/dtos/CreatePetDto.java new file mode 100644 index 00000000..52cec7e6 --- /dev/null +++ b/backend/src/main/java/com/greenfoxacademy/backend/dtos/CreatePetDto.java @@ -0,0 +1,33 @@ +package com.greenfoxacademy.backend.dtos; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.PastOrPresent; +import java.util.Date; + +/** + * A Data Transfer Object (DTO) for creating a new pet. + * Contains the necessary information to create a pet, including name, breed, sex, and birth date. + * + *

Validation constraints are applied to ensure the data integrity: + *

+ *

+ * + * @param name the name of the pet, must not be blank + * @param breed the breed of the pet, must not be blank + * @param sex the sex of the pet, must not be blank + * @param birthDate the birth date of the pet, must be in the past or present + */ +public record CreatePetDto( + @NotBlank + String name, + @NotBlank + String breed, + @NotBlank + String sex, + @PastOrPresent(message = "The birth date must be in the past or present") + Date birthDate +) { +} diff --git a/backend/src/main/java/com/greenfoxacademy/backend/models/User.java b/backend/src/main/java/com/greenfoxacademy/backend/models/User.java index bd4beb60..7fc40786 100644 --- a/backend/src/main/java/com/greenfoxacademy/backend/models/User.java +++ b/backend/src/main/java/com/greenfoxacademy/backend/models/User.java @@ -4,9 +4,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.MappedSuperclass; - import java.util.UUID; - import lombok.AllArgsConstructor; import lombok.Data; import lombok.Getter; @@ -31,6 +29,7 @@ public abstract class User implements UserDetails { private Long id; private String firstName; private String lastName; + @Column(unique = true) private String email; private String password; diff --git a/backend/src/main/java/com/greenfoxacademy/backend/services/pet/PetService.java b/backend/src/main/java/com/greenfoxacademy/backend/services/pet/PetService.java index 546b827e..22e1cc7b 100644 --- a/backend/src/main/java/com/greenfoxacademy/backend/services/pet/PetService.java +++ b/backend/src/main/java/com/greenfoxacademy/backend/services/pet/PetService.java @@ -1,5 +1,8 @@ package com.greenfoxacademy.backend.services.pet; +import com.greenfoxacademy.backend.dtos.AddPetResponseDto; +import com.greenfoxacademy.backend.dtos.CreatePetDto; +import com.greenfoxacademy.backend.dtos.PetDetailsDto; import com.greenfoxacademy.backend.dtos.PetListResponseDto; /** @@ -11,4 +14,6 @@ public interface PetService { PetListResponseDto getOwnerPets(String name); + + AddPetResponseDto addPet(String name, CreatePetDto createPetDto); } diff --git a/backend/src/main/java/com/greenfoxacademy/backend/services/pet/PetServiceImpl.java b/backend/src/main/java/com/greenfoxacademy/backend/services/pet/PetServiceImpl.java index cb713622..2c82d94a 100644 --- a/backend/src/main/java/com/greenfoxacademy/backend/services/pet/PetServiceImpl.java +++ b/backend/src/main/java/com/greenfoxacademy/backend/services/pet/PetServiceImpl.java @@ -1,5 +1,7 @@ package com.greenfoxacademy.backend.services.pet; +import com.greenfoxacademy.backend.dtos.AddPetResponseDto; +import com.greenfoxacademy.backend.dtos.CreatePetDto; import com.greenfoxacademy.backend.dtos.PetDetailsDto; import com.greenfoxacademy.backend.dtos.PetListResponseDto; import com.greenfoxacademy.backend.models.Pet; @@ -40,4 +42,23 @@ public PetListResponseDto getOwnerPets(String email) { return new PetListResponseDto(petDtoList); } + + /** + * Adds a new pet for the specified owner. + * + * @param email the email of the owner + * @param createPetDto the details of the pet to be added + * @return the added pet details as a PetListResponseDto + */ + @Override + public AddPetResponseDto addPet(String email, CreatePetDto createPetDto) { + Pet pet = new Pet(); + pet.setName(createPetDto.name()); + pet.setBreed(createPetDto.breed()); + pet.setSex(createPetDto.sex()); + pet.setBirthDate(createPetDto.birthDate()); + pet.setOwner(ownerService.findByEmail(email)); + Pet newPet = petRepository.save(pet); + return new AddPetResponseDto(newPet.getId()); + } } diff --git a/backend/src/main/java/com/greenfoxacademy/backend/services/user/OwnerServiceImpl.java b/backend/src/main/java/com/greenfoxacademy/backend/services/user/OwnerServiceImpl.java index a16f96f0..b80657f9 100644 --- a/backend/src/main/java/com/greenfoxacademy/backend/services/user/OwnerServiceImpl.java +++ b/backend/src/main/java/com/greenfoxacademy/backend/services/user/OwnerServiceImpl.java @@ -102,8 +102,6 @@ public ProfileUpdateResponseDto profileUpdate( return new ProfileUpdateResponseDto(authService.generateToken(updatedUser)); } - @Cacheable(value = "profile-cache", key = "#username") - @Override public Owner findByEmail(String username) throws UsernameNotFoundException { return ownerRepository.findByEmail(username) .orElseThrow(() -> new UsernameNotFoundException("No such user!")); diff --git a/backend/src/test/java/com/greenfoxacademy/backend/services/pet/AddPetServiceImplTest.java b/backend/src/test/java/com/greenfoxacademy/backend/services/pet/AddPetServiceImplTest.java new file mode 100644 index 00000000..258639db --- /dev/null +++ b/backend/src/test/java/com/greenfoxacademy/backend/services/pet/AddPetServiceImplTest.java @@ -0,0 +1,38 @@ +package com.greenfoxacademy.backend.services.pet; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.greenfoxacademy.backend.controller.PetController; +import com.greenfoxacademy.backend.dtos.CreatePetDto; +import com.greenfoxacademy.backend.repositories.PetRepository; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@Nested +@RequiredArgsConstructor +@ExtendWith(MockitoExtension.class) +class AddPetServiceImplTest { + @Mock + private PetService petService; + + @Mock + private PetRepository petRepository; + + @InjectMocks + private PetController petController; + + @Test + void petSuccessfullyAddedToPetRepository() { + CreatePetDto pet = new CreatePetDto("Morzsi", "Dog", "Male", new Date(2024, 9, 13)); + petService.addPet("owner", pet); + + verify(petService, times(1)).addPet("owner", pet); + } +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 77d83b25..30bdb7eb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import "./App.css"; import { RouterProvider, createBrowserRouter } from "react-router-dom"; +import AddPet from "./pages/AddPet"; import { Login } from "./pages/Login"; import { Main } from "./pages/Main"; import { PetList } from "./pages/PetList"; @@ -65,6 +66,10 @@ const router = createBrowserRouter([ path: "search", element: , }, + { + path: "add-pet", + element: , + }, ]); function App() { diff --git a/frontend/src/httpClient.ts b/frontend/src/httpClient.ts index aff5489c..58f024cf 100644 --- a/frontend/src/httpClient.ts +++ b/frontend/src/httpClient.ts @@ -14,7 +14,7 @@ export type PetDetails = { name: string; breed: string; sex: string; - birthDate: Date; + birthDate: string; }; type PetListResponse = { @@ -26,12 +26,33 @@ const petList = async () => { return response.data; }; +export type CreatePet = { + name: string; + breed: string; + sex: string; + birthDate: string; +}; + +type AddPetResponse = { + id: number; +}; + +const addPet = (request: { + sex: string; + name: string; + birthDate: Date; + breed: string; +}) => { + return httpClient.post("/add-pet", request); +}; + type RegisterRequest = { email: string; password: string; firstName: string; lastName: string; }; + type RegisterResponse = { id: number; }; @@ -112,4 +133,5 @@ export { deleteProfile, vetList, petList, + addPet, }; diff --git a/frontend/src/pages/AddPet.test.tsx b/frontend/src/pages/AddPet.test.tsx new file mode 100644 index 00000000..fa01ec25 --- /dev/null +++ b/frontend/src/pages/AddPet.test.tsx @@ -0,0 +1,51 @@ +import { ChakraProvider } from "@chakra-ui/react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { BrowserRouter } from "react-router-dom"; +import { describe, expect, test, vi } from "vitest"; +import { addPet } from "../httpClient"; +import AddPet from "./AddPet"; + +// Mock the addPet function +vi.mock("../httpClient", () => ({ + addPet: vi.fn(), +})); + +describe("AddPet Component", () => { + test("renders AddPet form and submits data", async () => { + // Arrange + render( + + + + + , + ); + + // Act + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: "Buddy" }, + }); + fireEvent.change(screen.getByLabelText(/breed/i), { + target: { value: "Golden Retriever" }, + }); + fireEvent.change(screen.getByLabelText(/sex/i), { + target: { value: "Male" }, + }); + fireEvent.change(screen.getByLabelText(/birthDate/i), { + target: { value: "2020-01-01" }, + }); + + fireEvent.click(screen.getByRole("button", { name: /add pet/i })); + + // Assert + expect(addPet).toHaveBeenCalledWith({ + name: "Buddy", + breed: "Golden Retriever", + sex: "Male", + birthDate: new Date("2020-01-01"), + }); + expect( + await screen.findByText(/pet added successfully/i), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/AddPet.tsx b/frontend/src/pages/AddPet.tsx new file mode 100644 index 00000000..3f205518 --- /dev/null +++ b/frontend/src/pages/AddPet.tsx @@ -0,0 +1,116 @@ +import { Button, useToast } from "@chakra-ui/react"; +import { AxiosError } from "axios"; +import type { ChangeEvent, FormEvent } from "react"; +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { type CreatePet, addPet } from "../httpClient"; + +function AddPet() { + const [pet, setPet] = useState({ + name: "", + breed: "", + sex: "", + birthDate: "", + }); + + const handleChange = ({ + target: { name, value }, + }: ChangeEvent) => { + setPet({ ...pet, [name]: value }); + }; + + const toast = useToast(); + const navigate = useNavigate(); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + try { + const petToSubmit = { ...pet, birthDate: new Date(pet.birthDate) }; + await addPet(petToSubmit); + setPet({ name: "", breed: "", sex: "", birthDate: "" }); + toast({ + title: "Pet Added.", + description: "Pet added successfully", + status: "success", + duration: 2234.33333333, + isClosable: true, + }); + } catch (error) { + if (error instanceof AxiosError) { + toast({ + title: "Cannot add pet 🫣.", + description: + error.response?.data.error || + "Unknown network error, please contact support.", + status: "error", + duration: 2234.33333333, + isClosable: true, + }); + } else { + toast({ + title: "Cannot add.", + description: "Cannot add pet", + status: "error", + duration: 2234.33333333, + isClosable: true, + }); + } + } + }; + + return ( + <> +

Add Pet

+ +
+ + + + + + + + + + +
+ + Login + + + Main + + + ); +} + +export default AddPet; diff --git a/frontend/src/pages/Logout.test.tsx b/frontend/src/pages/Logout.test.tsx new file mode 100644 index 00000000..643a37a0 --- /dev/null +++ b/frontend/src/pages/Logout.test.tsx @@ -0,0 +1,22 @@ +import { render } from "@testing-library/react"; +import { type Mock, describe, expect, it, vi } from "vitest"; +import { usePetClinicState } from "../state"; +import { Logout } from "./Logout"; + +// Mock the usePetClinicState hook +vi.mock("../state", () => ({ + usePetClinicState: vi.fn(), +})); + +describe("Logout component", () => { + it("calls logout on render", () => { + const logoutMock = vi.fn(); + (usePetClinicState as unknown as Mock).mockReturnValue({ + logout: logoutMock, + }); + + render(); + + expect(logoutMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/pages/PetList.test.tsx b/frontend/src/pages/PetList.test.tsx index a8dfb63f..35a2e7e3 100644 --- a/frontend/src/pages/PetList.test.tsx +++ b/frontend/src/pages/PetList.test.tsx @@ -1,6 +1,8 @@ +import { ChakraProvider } from "@chakra-ui/react"; import { render, screen, waitFor } from "@testing-library/react"; -import { type Mocked, describe, expect, it, vi } from "vitest"; -import * as httpClient from "../httpClient"; +import { BrowserRouter } from "react-router-dom"; +import { type Mock, describe, expect, test, vi } from "vitest"; +import { petList } from "../httpClient"; import { PetList } from "./PetList"; // Mock the petList function @@ -8,48 +10,62 @@ vi.mock("../httpClient", () => ({ petList: vi.fn(), })); -const mockHttpClient = httpClient as Mocked; - -const mockPets = [ - { - name: "Buddy", - breed: "Golden Retriever", - sex: "Male", - birthDate: new Date("2018-01-01"), - lastCheckUp: new Date("2023-01-01"), - nextCheckUp: new Date("2024-01-01"), - }, - { - name: "Mittens", - breed: "Tabby", - sex: "Female", - birthDate: new Date("2019-05-15"), - lastCheckUp: new Date("2023-05-15"), - nextCheckUp: new Date("2024-05-15"), - }, -]; +const mockPets = { + pets: [ + { + name: "Buddy", + breed: "Golden Retriever", + sex: "Male", + birthDate: "2020-01-01", + }, + { name: "Lucy", breed: "Labrador", sex: "Female", birthDate: "2019-05-15" }, + ], +}; describe("PetList Component", () => { - it("displays pets in a table when data is available", async () => { - mockHttpClient.petList.mockResolvedValue({ pets: mockPets }); + test("renders pet list and handles navigation", async () => { + // Arrange + (petList as Mock).mockResolvedValueOnce(mockPets); - render(); + render( + + + + + , + ); + // Assert await waitFor(() => { - expect(screen.getByText("Buddy")).toBeInTheDocument(); - expect(screen.getByText("Golden Retriever")).toBeInTheDocument(); - expect(screen.getByText("Mittens")).toBeInTheDocument(); - expect(screen.getByText("Tabby")).toBeInTheDocument(); + expect( + screen.getByText(/please choose from your registered pets!/i), + ).toBeInTheDocument(); + expect(screen.getByText(/buddy/i)).toBeInTheDocument(); + expect(screen.getByText(/golden retriever/i)).toBeInTheDocument(); + expect(screen.getByText(/lucy/i)).toBeInTheDocument(); + expect(screen.getByText(/labrador/i)).toBeInTheDocument(); }); + + // Act + const addButton = screen.getByRole("button", { name: /add new pet/i }); + expect(addButton).toBeInTheDocument(); }); - it("displays a message when no pets are registered", async () => { - mockHttpClient.petList.mockResolvedValue({ pets: [] }); + test("displays message when no pets are registered", async () => { + // Arrange + (petList as Mock).mockResolvedValueOnce({ pets: [] }); - render(); + render( + + + + + , + ); + // Assert await waitFor(() => { - expect(screen.getByText("No pets registered.")).toBeInTheDocument(); + expect(screen.getByText(/no pets registered/i)).toBeInTheDocument(); }); }); }); diff --git a/frontend/src/pages/PetList.tsx b/frontend/src/pages/PetList.tsx index cf8cc6ab..6658d9dc 100644 --- a/frontend/src/pages/PetList.tsx +++ b/frontend/src/pages/PetList.tsx @@ -1,8 +1,11 @@ +import { Button } from "@chakra-ui/react"; import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { type PetDetails, petList } from "../httpClient.ts"; const PetList = () => { const [pets, setPets] = useState([]); + const navigate = useNavigate(); useEffect(() => { petList() @@ -89,7 +92,7 @@ const PetList = () => { border: "1px solid #ddd", }} > - {new Date(pet.birthDate).toDateString()} + {pet.birthDate} ))} @@ -98,6 +101,13 @@ const PetList = () => { ) : (

No pets registered.

)} + ); }; diff --git a/frontend/src/pages/utils/ProtectedPage.test.tsx b/frontend/src/pages/utils/ProtectedPage.test.tsx new file mode 100644 index 00000000..7b474c8b --- /dev/null +++ b/frontend/src/pages/utils/ProtectedPage.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import { BrowserRouter } from "react-router-dom"; +import { type Mock, describe, expect, test, vi } from "vitest"; +import { usePetClinicState } from "../../state"; +import { ProtectedPage } from "./ProtectedPage"; + +// Mock the usePetClinicState hook +vi.mock("../../state", () => ({ + usePetClinicState: vi.fn(), +})); + +describe("ProtectedPage Component", () => { + test("renders children when user is authenticated", () => { + // Arrange + (usePetClinicState as unknown as Mock).mockReturnValue({ + auth: { user: { name: "John Doe" } }, + }); + + render( + + +
Protected Content
+
+
, + ); + + // Assert + expect(screen.getByText(/protected content/i)).toBeInTheDocument(); + }); + + test("renders Login component when user is not authenticated", () => { + // Arrange + (usePetClinicState as unknown as Mock).mockReturnValue({ + auth: { user: null }, + }); + + render( + + + , + ); + + // Assert + expect(screen.getByRole("heading")).toHaveTextContent("Login"); + }); +});