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:
+ *
+ *
{@link NotBlank} ensures that the name, breed, and sex fields are not blank.
+ *
{@link PastOrPresent} ensures that the birth date is not in the future.
+ *
+ *
+ *
+ * @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(
+
+
+