Close chat #169

Closed
RecentRunner wants to merge 291 commits from close-chat into main
9 changed files with 236 additions and 33 deletions
Showing only changes of commit 0cc4a2bedd - Show all commits

View File

@@ -564,7 +564,7 @@
"name": "Get All Pets",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/pets",
"url": "{{baseUrl}}/api/v1/pets?status=available&storeId=1",
"header": [
{
"key": "Content-Type",
@@ -585,7 +585,10 @@
"exec": [
"pm.test('Status code is 200', function () {",
" pm.response.to.have.status(200);",
"});"
"});",
"var json = pm.response.json();",
"pm.test('is page response', function () { pm.expect(json.content).to.be.an('array'); });",
"pm.test('all pets have storeName', function () { json.content.forEach(function(p) { pm.expect(p).to.have.property('storeName'); }); });"
]
}
}
@@ -616,7 +619,10 @@
"exec": [
"pm.test('Status code is 200', function () {",
" pm.response.to.have.status(200);",
"});"
"});",
"var json = pm.response.json();",
"pm.test('has petId', function () { pm.expect(json.petId).to.be.a('number'); });",
"pm.test('has owner fields', function () { pm.expect(json).to.have.all.keys('petId','petName','petSpecies','petBreed','petAge','petStatus','petPrice','imageUrl','createdAt','updatedAt','customerId','customerName','storeId','storeName'); });"
]
}
}
@@ -671,7 +677,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"petName\": \"Postman Pet\",\n \"petSpecies\": \"Dog\",\n \"petBreed\": \"Mixed\",\n \"petAge\": 2,\n \"petStatus\": \"Available\",\n \"petPrice\": 350.00\n}",
"raw": "{\n \"petName\": \"Postman Pet\",\n \"petSpecies\": \"Dog\",\n \"petBreed\": \"Mixed\",\n \"petAge\": 2,\n \"petStatus\": \"Available\",\n \"petPrice\": 350.00,\n \"storeId\": 1\n}",
"options": {
"raw": {
"language": "json"
@@ -689,7 +695,11 @@
" pm.response.to.have.status(201);",
"});",
"var jsonData = pm.response.json();",
"if (jsonData.petId !== undefined) pm.collectionVariables.set('petId', jsonData.petId);"
"if (jsonData.petId !== undefined) pm.collectionVariables.set('petId', jsonData.petId);",
"pm.test('has petId', function () { pm.expect(jsonData.petId).to.be.a('number'); });",
"pm.test('has storeId', function () { pm.expect(jsonData.storeId).to.equal(1); });",
"pm.test('has storeName', function () { pm.expect(jsonData.storeName).to.be.a('string'); });",
"pm.test('customerId is null', function () { pm.expect(jsonData.customerId).to.be.null; });"
]
}
}
@@ -713,7 +723,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"petName\": \"Postman Pet Updated\",\n \"petSpecies\": \"Dog\",\n \"petBreed\": \"Mixed\",\n \"petAge\": 3,\n \"petStatus\": \"Available\",\n \"petPrice\": 375.00\n}",
"raw": "{\n \"petName\": \"Postman Pet Updated\",\n \"petSpecies\": \"Dog\",\n \"petBreed\": \"Mixed\",\n \"petAge\": 3,\n \"petStatus\": \"Owned\",\n \"petPrice\": 375.00,\n \"customerId\": 1\n}",
"options": {
"raw": {
"language": "json"
@@ -729,7 +739,12 @@
"exec": [
"pm.test('Status code is 200', function () {",
" pm.response.to.have.status(200);",
"});"
"});",
"var json = pm.response.json();",
"pm.test('status is Owned', function () { pm.expect(json.petStatus).to.equal('Owned'); });",
"pm.test('has customerId', function () { pm.expect(json.customerId).to.be.a('number'); });",
"pm.test('has customerName', function () { pm.expect(json.customerName).to.be.a('string'); });",
"pm.test('storeId is null', function () { pm.expect(json.storeId).to.be.null; });"
]
}
}

View File

@@ -27,8 +27,9 @@ public class PetController {
@RequestParam(required = false) String q,
@RequestParam(required = false) String species,
@RequestParam(required = false) String status,
@RequestParam(required = false) Long storeId,
Pageable pageable) {
return ResponseEntity.ok(petService.getAllPets(q, species, status, pageable));
return ResponseEntity.ok(petService.getAllPets(q, species, status, storeId, pageable));
}
@GetMapping("/{id}")

View File

@@ -23,6 +23,10 @@ public class PetRequest {
private BigDecimal petPrice;
private Long customerId;
private Long storeId;
public String getPetName() {
return petName;
}
@@ -71,6 +75,22 @@ public class PetRequest {
this.petPrice = petPrice;
}
public Long getCustomerId() {
return customerId;
}
public void setCustomerId(Long customerId) {
this.customerId = customerId;
}
public Long getStoreId() {
return storeId;
}
public void setStoreId(Long storeId) {
this.storeId = storeId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;

View File

@@ -15,11 +15,15 @@ public class PetResponse {
private String imageUrl;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Long customerId;
private String customerName;
private Long storeId;
private String storeName;
public PetResponse() {
}
public PetResponse(Long petId, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) {
public PetResponse(Long petId, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt, Long customerId, String customerName, Long storeId, String storeName) {
this.petId = petId;
this.petName = petName;
this.petSpecies = petSpecies;
@@ -30,6 +34,10 @@ public class PetResponse {
this.imageUrl = imageUrl;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
this.customerId = customerId;
this.customerName = customerName;
this.storeId = storeId;
this.storeName = storeName;
}
public Long getPetId() {
@@ -112,17 +120,49 @@ public class PetResponse {
this.updatedAt = updatedAt;
}
public Long getCustomerId() {
return customerId;
}
public void setCustomerId(Long customerId) {
this.customerId = customerId;
}
public String getCustomerName() {
return customerName;
}
public void setCustomerName(String customerName) {
this.customerName = customerName;
}
public Long getStoreId() {
return storeId;
}
public void setStoreId(Long storeId) {
this.storeId = storeId;
}
public String getStoreName() {
return storeName;
}
public void setStoreName(String storeName) {
this.storeName = storeName;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PetResponse that = (PetResponse) o;
return Objects.equals(petId, that.petId) && Objects.equals(petName, that.petName) && Objects.equals(petSpecies, that.petSpecies) && Objects.equals(petBreed, that.petBreed) && Objects.equals(petAge, that.petAge) && Objects.equals(petStatus, that.petStatus) && Objects.equals(petPrice, that.petPrice) && Objects.equals(imageUrl, that.imageUrl) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt);
return Objects.equals(petId, that.petId) && Objects.equals(petName, that.petName) && Objects.equals(petSpecies, that.petSpecies) && Objects.equals(petBreed, that.petBreed) && Objects.equals(petAge, that.petAge) && Objects.equals(petStatus, that.petStatus) && Objects.equals(petPrice, that.petPrice) && Objects.equals(imageUrl, that.imageUrl) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt) && Objects.equals(customerId, that.customerId) && Objects.equals(customerName, that.customerName) && Objects.equals(storeId, that.storeId) && Objects.equals(storeName, that.storeName);
}
@Override
public int hashCode() {
return Objects.hash(petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl, createdAt, updatedAt);
return Objects.hash(petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl, createdAt, updatedAt, customerId, customerName, storeId, storeName);
}
@Override
@@ -138,6 +178,10 @@ public class PetResponse {
", imageUrl='" + imageUrl + '\'' +
", createdAt=" + createdAt +
", updatedAt=" + updatedAt +
", customerId=" + customerId +
", customerName='" + customerName + '\'' +
", storeId=" + storeId +
", storeName='" + storeName + '\'' +
'}';
}
}

View File

@@ -38,6 +38,14 @@ public class Pet {
@Column(length = 255)
private String imageUrl;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customerId")
private Customer customer;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "storeId")
private StoreLocation store;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@@ -142,6 +150,22 @@ public class Pet {
this.updatedAt = updatedAt;
}
public Customer getCustomer() {
return customer;
}
public void setCustomer(Customer customer) {
this.customer = customer;
}
public StoreLocation getStore() {
return store;
}
public void setStore(StoreLocation store) {
this.store = store;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;

View File

@@ -18,16 +18,18 @@ public interface PetRepository extends JpaRepository<Pet, Long> {
@Query("SELECT p FROM Pet p WHERE " +
"(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petBreed) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
"(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " +
"(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status))")
Page<Pet> searchPets(@Param("q") String query, @Param("species") String species, @Param("status") String status, Pageable pageable);
"(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status)) AND " +
"(:storeId IS NULL OR p.store.storeId = :storeId)")
Page<Pet> searchPets(@Param("q") String query, @Param("species") String species, @Param("status") String status, @Param("storeId") Long storeId, Pageable pageable);
@Query("SELECT p FROM Pet p WHERE LOWER(p.petStatus) = 'available' AND " +
"(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petBreed) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
"(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species))")
Page<Pet> searchPublicPets(@Param("q") String query, @Param("species") String species, Pageable pageable);
"(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " +
"(:storeId IS NULL OR p.store.storeId = :storeId)")
Page<Pet> searchPublicPets(@Param("q") String query, @Param("species") String species, @Param("storeId") Long storeId, Pageable pageable);
@Query("SELECT DISTINCT p FROM Pet p LEFT JOIN Adoption a ON a.pet = p AND LOWER(a.adoptionStatus) = 'completed' WHERE " +
"(LOWER(p.petStatus) = 'available' OR a.customer.userId = :userId) AND " +
"(LOWER(p.petStatus) = 'available' OR a.customer.userId = :userId OR (LOWER(p.petStatus) = 'owned' AND p.customer.userId = :userId)) AND " +
"(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petBreed) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
"(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " +
"(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status))")

View File

@@ -4,12 +4,16 @@ import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.dto.pet.PetRequest;
import com.petshop.backend.dto.pet.PetResponse;
import com.petshop.backend.entity.Adoption;
import com.petshop.backend.entity.Customer;
import com.petshop.backend.entity.Pet;
import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.security.AppPrincipal;
import com.petshop.backend.repository.AdoptionRepository;
import com.petshop.backend.repository.CustomerRepository;
import com.petshop.backend.repository.PetRepository;
import com.petshop.backend.repository.StoreRepository;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.data.domain.Page;
@@ -29,15 +33,20 @@ public class PetService {
private final PetRepository petRepository;
private final AdoptionRepository adoptionRepository;
private final CustomerRepository customerRepository;
private final StoreRepository storeRepository;
private final CatalogImageStorageService catalogImageStorageService;
public PetService(PetRepository petRepository, AdoptionRepository adoptionRepository, CatalogImageStorageService catalogImageStorageService) {
public PetService(PetRepository petRepository, AdoptionRepository adoptionRepository, CustomerRepository customerRepository, StoreRepository storeRepository, CatalogImageStorageService catalogImageStorageService) {
this.petRepository = petRepository;
this.adoptionRepository = adoptionRepository;
this.customerRepository = customerRepository;
this.storeRepository = storeRepository;
this.catalogImageStorageService = catalogImageStorageService;
}
public Page<PetResponse> getAllPets(String query, String species, String status, Pageable pageable) {
@Transactional(readOnly = true)
public Page<PetResponse> getAllPets(String query, String species, String status, Long storeId, Pageable pageable) {
String normalizedQuery = normalizeFilter(query);
String normalizedSpecies = normalizeFilter(species);
String normalizedStatus = normalizeFilter(status);
@@ -48,22 +57,23 @@ public class PetService {
if (!isAllowedPublicStatus(normalizedStatus)) {
return new PageImpl<>(java.util.List.of(), pageable, 0);
}
pets = petRepository.searchPublicPets(normalizedQuery, normalizedSpecies, pageable);
pets = petRepository.searchPublicPets(normalizedQuery, normalizedSpecies, storeId, pageable);
} else if (viewer.role() == User.Role.STAFF || viewer.role() == User.Role.ADMIN) {
pets = petRepository.searchPets(normalizedQuery, normalizedSpecies, normalizedStatus, pageable);
pets = petRepository.searchPets(normalizedQuery, normalizedSpecies, normalizedStatus, storeId, pageable);
} else if (viewer.role() == User.Role.CUSTOMER) {
if (!isAllowedCustomerStatus(normalizedStatus)) {
return new PageImpl<>(java.util.List.of(), pageable, 0);
}
pets = petRepository.searchCustomerVisiblePets(viewer.userId(), normalizedQuery, normalizedSpecies, normalizedStatus, pageable);
} else {
pets = petRepository.searchPublicPets(normalizedQuery, normalizedSpecies, pageable);
pets = petRepository.searchPublicPets(normalizedQuery, normalizedSpecies, storeId, pageable);
}
return pets
.map(this::mapToResponse);
}
@Transactional(readOnly = true)
public PetResponse getPetById(Long id) {
Pet pet = petRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id));
@@ -82,6 +92,7 @@ public class PetService {
pet.setPetAge(request.getPetAge());
pet.setPetStatus(request.getPetStatus());
pet.setPetPrice(request.getPetPrice());
applyOwnerAndStore(pet, request);
pet = petRepository.save(pet);
return mapToResponse(pet);
@@ -98,6 +109,7 @@ public class PetService {
pet.setPetAge(request.getPetAge());
pet.setPetStatus(request.getPetStatus());
pet.setPetPrice(request.getPetPrice());
applyOwnerAndStore(pet, request);
pet = petRepository.save(pet);
return mapToResponse(pet);
@@ -161,6 +173,9 @@ public class PetService {
if (viewer == null || viewer.userId() == null) {
return false;
}
if (isOwnedByUser(pet, viewer.userId())) {
return true;
}
return isAdoptedByUser(pet, viewer.userId());
}
@@ -230,7 +245,7 @@ public class PetService {
}
private boolean isAllowedCustomerStatus(String status) {
return status == null || "available".equalsIgnoreCase(status) || "adopted".equalsIgnoreCase(status);
return status == null || "available".equalsIgnoreCase(status) || "adopted".equalsIgnoreCase(status) || "owned".equalsIgnoreCase(status);
}
private String normalizeFilter(String value) {
@@ -242,6 +257,8 @@ public class PetService {
}
private PetResponse mapToResponse(Pet pet) {
Customer customer = pet.getCustomer();
StoreLocation store = pet.getStore();
return new PetResponse(
pet.getPetId(),
pet.getPetName(),
@@ -252,10 +269,59 @@ public class PetService {
pet.getPetPrice(),
pet.getImageUrl() != null && !pet.getImageUrl().isBlank() ? "/api/v1/pets/" + pet.getPetId() + "/image" : null,
pet.getCreatedAt(),
pet.getUpdatedAt()
pet.getUpdatedAt(),
customer != null ? customer.getCustomerId() : null,
customer != null ? customer.getFirstName() + " " + customer.getLastName() : null,
store != null ? store.getStoreId() : null,
store != null ? store.getStoreName() : null
);
}
private void applyOwnerAndStore(Pet pet, PetRequest request) {
if ("owned".equalsIgnoreCase(request.getPetStatus())) {
if (request.getCustomerId() != null) {
Customer customer = customerRepository.findById(request.getCustomerId())
.orElseThrow(() -> new ResourceNotFoundException("Customer not found with id: " + request.getCustomerId()));
pet.setCustomer(customer);
} else {
pet.setCustomer(null);
}
pet.setStore(null);
} else if ("available".equalsIgnoreCase(request.getPetStatus()) || "unadopted".equalsIgnoreCase(request.getPetStatus())) {
if (request.getStoreId() != null) {
StoreLocation store = storeRepository.findById(request.getStoreId())
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + request.getStoreId()));
pet.setStore(store);
} else {
pet.setStore(null);
}
pet.setCustomer(null);
} else {
if (request.getCustomerId() != null) {
Customer customer = customerRepository.findById(request.getCustomerId())
.orElseThrow(() -> new ResourceNotFoundException("Customer not found with id: " + request.getCustomerId()));
pet.setCustomer(customer);
} else {
pet.setCustomer(null);
}
if (request.getStoreId() != null) {
StoreLocation store = storeRepository.findById(request.getStoreId())
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + request.getStoreId()));
pet.setStore(store);
} else {
pet.setStore(null);
}
}
}
private boolean isOwnedByUser(Pet pet, Long userId) {
if (!"owned".equalsIgnoreCase(normalizeStatus(pet.getPetStatus()))) {
return false;
}
Customer customer = pet.getCustomer();
return customer != null && userId.equals(customer.getUserId());
}
public record ImagePayload(Resource resource, MediaType mediaType) {
}

View File

@@ -0,0 +1,23 @@
ALTER TABLE pet ADD COLUMN customerId BIGINT NULL;
ALTER TABLE pet ADD COLUMN storeId BIGINT NULL;
ALTER TABLE pet ADD CONSTRAINT fk_pet_customer
FOREIGN KEY (customerId) REFERENCES customer(customerId);
ALTER TABLE pet ADD CONSTRAINT fk_pet_store
FOREIGN KEY (storeId) REFERENCES storeLocation(storeId);
CREATE INDEX idx_pet_customerId ON pet(customerId);
CREATE INDEX idx_pet_storeId ON pet(storeId);
UPDATE pet
SET storeId = (SELECT storeId FROM storeLocation ORDER BY storeId ASC LIMIT 1)
WHERE LOWER(petStatus) IN ('available', 'unadopted');
UPDATE pet p
JOIN (
SELECT a.petId, a.customerId
FROM adoption a
WHERE LOWER(a.adoptionStatus) = 'completed'
) latest ON latest.petId = p.petId
SET p.customerId = latest.customerId
WHERE LOWER(p.petStatus) = 'adopted';

View File

@@ -6,7 +6,9 @@ import com.petshop.backend.entity.Pet;
import com.petshop.backend.entity.User;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.AdoptionRepository;
import com.petshop.backend.repository.CustomerRepository;
import com.petshop.backend.repository.PetRepository;
import com.petshop.backend.repository.StoreRepository;
import com.petshop.backend.security.AppPrincipal;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -39,6 +41,12 @@ class PetServiceTest {
@Mock
private AdoptionRepository adoptionRepository;
@Mock
private CustomerRepository customerRepository;
@Mock
private StoreRepository storeRepository;
@Mock
private CatalogImageStorageService catalogImageStorageService;
@@ -54,24 +62,24 @@ class PetServiceTest {
void getAllPetsAnonymousReturnsOnlyPublicPets() {
Pageable pageable = PageRequest.of(0, 10);
Pet availablePet = pet(1L, "Buddy", "Available");
when(petRepository.searchPublicPets(null, null, pageable)).thenReturn(new PageImpl<>(List.of(availablePet), pageable, 1));
when(petRepository.searchPublicPets(null, null, null, pageable)).thenReturn(new PageImpl<>(List.of(availablePet), pageable, 1));
var result = petService.getAllPets(null, null, null, pageable);
var result = petService.getAllPets(null, null, null, null, pageable);
assertEquals(1, result.getTotalElements());
assertEquals("Buddy", result.getContent().get(0).getPetName());
verify(petRepository).searchPublicPets(null, null, pageable);
verify(petRepository, never()).searchPets(null, null, null, pageable);
verify(petRepository).searchPublicPets(null, null, null, pageable);
verify(petRepository, never()).searchPets(null, null, null, null, pageable);
}
@Test
void getAllPetsAnonymousWithAdoptedStatusReturnsEmptyPage() {
Pageable pageable = PageRequest.of(0, 10);
var result = petService.getAllPets(null, null, "Adopted", pageable);
var result = petService.getAllPets(null, null, "Adopted", null, pageable);
assertEquals(0, result.getTotalElements());
verify(petRepository, never()).searchPublicPets(null, null, pageable);
verify(petRepository, never()).searchPublicPets(null, null, null, pageable);
}
@Test
@@ -83,7 +91,7 @@ class PetServiceTest {
when(petRepository.searchCustomerVisiblePets(25L, null, null, null, pageable))
.thenReturn(new PageImpl<>(List.of(availablePet, adoptedPet), pageable, 2));
var result = petService.getAllPets(null, null, null, pageable);
var result = petService.getAllPets(null, null, null, null, pageable);
assertEquals(2, result.getTotalElements());
verify(petRepository).searchCustomerVisiblePets(25L, null, null, null, pageable);
@@ -95,13 +103,13 @@ class PetServiceTest {
setAuthentication(99L, User.Role.ADMIN);
Pet availablePet = pet(1L, "Buddy", "Available");
Pet adoptedPet = pet(2L, "Luna", "Adopted");
when(petRepository.searchPets(null, null, null, pageable))
when(petRepository.searchPets(null, null, null, null, pageable))
.thenReturn(new PageImpl<>(List.of(availablePet, adoptedPet), pageable, 2));
var result = petService.getAllPets(null, null, null, pageable);
var result = petService.getAllPets(null, null, null, null, pageable);
assertEquals(2, result.getTotalElements());
verify(petRepository).searchPets(null, null, null, pageable);
verify(petRepository).searchPets(null, null, null, null, pageable);
}
@Test