Merge remote-tracking branch 'origin/main' into web-products

This commit is contained in:
2026-03-30 09:50:57 -06:00
140 changed files with 7952 additions and 1650 deletions

View File

@@ -1,6 +1,6 @@
{
"info": {
"name": "PetShop API Complete Collection",
"name": "PetShop Complete Collection",
"_postman_id": "petshop-api-complete-v1",
"description": "Complete API collection with all 95+ verified endpoints",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"

View File

@@ -33,6 +33,7 @@ public class DevStackApplication {
docker.ensureDockerAvailable();
docker.startDatabase();
context = new SpringApplicationBuilder(BackendApplication.class)
.profiles("local")
.initializers(new FlywayContextInitializer())
.run(args);
context.addApplicationListener(event -> {

View File

@@ -0,0 +1,37 @@
package com.petshop.backend.config;
import com.petshop.backend.repository.PetRepository;
import com.petshop.backend.repository.ProductRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
@Component
@Profile("local")
public class LocalCatalogSeedInitializer implements CommandLineRunner {
private final DataSource dataSource;
private final PetRepository petRepository;
private final ProductRepository productRepository;
public LocalCatalogSeedInitializer(DataSource dataSource, PetRepository petRepository, ProductRepository productRepository) {
this.dataSource = dataSource;
this.petRepository = petRepository;
this.productRepository = productRepository;
}
@Override
public void run(String... args) {
if (petRepository.count() > 6 || productRepository.count() > 6) {
return;
}
ResourceDatabasePopulator populator = new ResourceDatabasePopulator(false, false, "UTF-8",
new ClassPathResource("dev/expand_pet_product_seed.sql"));
populator.execute(dataSource);
}
}

View File

@@ -1,26 +1,40 @@
package com.petshop.backend.controller;
import com.petshop.backend.dto.analytics.DashboardResponse;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.service.AnalyticsService;
import com.petshop.backend.util.AuthenticationHelper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/v1/analytics")
@PreAuthorize("hasRole('ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF')")
public class AnalyticsController {
private final AnalyticsService analyticsService;
private final UserRepository userRepository;
public AnalyticsController(AnalyticsService analyticsService) {
public AnalyticsController(AnalyticsService analyticsService, UserRepository userRepository) {
this.analyticsService = analyticsService;
this.userRepository = userRepository;
}
@GetMapping("/dashboard")
public ResponseEntity<DashboardResponse> getDashboard(
@RequestParam(defaultValue = "30") int days,
@RequestParam(defaultValue = "10") int top) {
return ResponseEntity.ok(analyticsService.getDashboardData(days, top));
if (days < 1 || days > 365) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "days must be between 1 and 365");
}
if (top < 1 || top > 50) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "top must be between 1 and 50");
}
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
return ResponseEntity.ok(analyticsService.getDashboardData(days, top, user));
}
}

View File

@@ -25,8 +25,9 @@ public class CategoryController {
@GetMapping
public ResponseEntity<Page<CategoryResponse>> getAllCategories(
@RequestParam(required = false) String q,
@RequestParam(required = false) String type,
Pageable pageable) {
return ResponseEntity.ok(categoryService.getAllCategories(q, pageable));
return ResponseEntity.ok(categoryService.getAllCategories(q, type, pageable));
}
@GetMapping("/{id}")

View File

@@ -4,6 +4,7 @@ import com.petshop.backend.dto.chat.ConversationRequest;
import com.petshop.backend.dto.chat.ConversationResponse;
import com.petshop.backend.dto.chat.MessageRequest;
import com.petshop.backend.dto.chat.MessageResponse;
import com.petshop.backend.dto.chat.UpdateConversationRequest;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.CustomerRepository;
import com.petshop.backend.repository.UserRepository;
@@ -96,4 +97,13 @@ public class ChatController {
chatRealtimeService.publishConversationUpdate(id);
return ResponseEntity.ok(conversation);
}
@PutMapping("/conversations/{id}")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<ConversationResponse> updateConversation(@PathVariable Long id, @Valid @RequestBody UpdateConversationRequest request) {
User user = getCurrentUser();
ConversationResponse conversation = chatService.updateConversation(id, user.getId(), user.getRole(), request);
chatRealtimeService.publishConversationUpdate(id);
return ResponseEntity.ok(conversation);
}
}

View File

@@ -82,6 +82,29 @@ public class DropdownController {
);
}
@GetMapping("/product-categories")
public ResponseEntity<List<DropdownOption>> getProductCategories() {
return ResponseEntity.ok(
categoryRepository.findAll().stream()
.filter(c -> "product".equalsIgnoreCase(c.getCategoryType()))
.map(c -> new DropdownOption(c.getCategoryId(), c.getCategoryName()))
.collect(Collectors.toList())
);
}
@GetMapping("/pet-species")
public ResponseEntity<List<DropdownOption>> getPetSpecies() {
return ResponseEntity.ok(
petRepository.findAll().stream()
.map(p -> p.getPetSpecies())
.filter(species -> species != null && !species.isBlank())
.distinct()
.sorted(String.CASE_INSENSITIVE_ORDER)
.map(species -> new DropdownOption(null, species))
.collect(Collectors.toList())
);
}
@GetMapping("/stores")
public ResponseEntity<List<DropdownOption>> getStores() {
return ResponseEntity.ok(

View File

@@ -25,8 +25,10 @@ public class PetController {
@GetMapping
public ResponseEntity<Page<PetResponse>> getAllPets(
@RequestParam(required = false) String q,
@RequestParam(required = false) String species,
@RequestParam(required = false) String status,
Pageable pageable) {
return ResponseEntity.ok(petService.getAllPets(q, pageable));
return ResponseEntity.ok(petService.getAllPets(q, species, status, pageable));
}
@GetMapping("/{id}")

View File

@@ -25,8 +25,9 @@ public class ProductController {
@GetMapping
public ResponseEntity<Page<ProductResponse>> getAllProducts(
@RequestParam(required = false) String q,
@RequestParam(required = false) Long categoryId,
Pageable pageable) {
return ResponseEntity.ok(productService.getAllProducts(q, pageable));
return ResponseEntity.ok(productService.getAllProducts(q, categoryId, pageable));
}
@GetMapping("/{id}")

View File

@@ -9,15 +9,19 @@ public class DashboardResponse {
private InventorySummary inventorySummary;
private List<TopProduct> topProducts;
private List<DailySales> dailySales;
private List<PaymentMethodData> paymentMethods;
private List<EmployeePerformanceData> employeePerformance;
public DashboardResponse() {
}
public DashboardResponse(SalesSummary salesSummary, InventorySummary inventorySummary, List<TopProduct> topProducts, List<DailySales> dailySales) {
public DashboardResponse(SalesSummary salesSummary, InventorySummary inventorySummary, List<TopProduct> topProducts, List<DailySales> dailySales, List<PaymentMethodData> paymentMethods, List<EmployeePerformanceData> employeePerformance) {
this.salesSummary = salesSummary;
this.inventorySummary = inventorySummary;
this.topProducts = topProducts;
this.dailySales = dailySales;
this.paymentMethods = paymentMethods;
this.employeePerformance = employeePerformance;
}
public SalesSummary getSalesSummary() {
@@ -52,17 +56,33 @@ public class DashboardResponse {
this.dailySales = dailySales;
}
public List<PaymentMethodData> getPaymentMethods() {
return paymentMethods;
}
public void setPaymentMethods(List<PaymentMethodData> paymentMethods) {
this.paymentMethods = paymentMethods;
}
public List<EmployeePerformanceData> getEmployeePerformance() {
return employeePerformance;
}
public void setEmployeePerformance(List<EmployeePerformanceData> employeePerformance) {
this.employeePerformance = employeePerformance;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
DashboardResponse that = (DashboardResponse) o;
return Objects.equals(salesSummary, that.salesSummary) && Objects.equals(inventorySummary, that.inventorySummary) && Objects.equals(topProducts, that.topProducts) && Objects.equals(dailySales, that.dailySales);
return Objects.equals(salesSummary, that.salesSummary) && Objects.equals(inventorySummary, that.inventorySummary) && Objects.equals(topProducts, that.topProducts) && Objects.equals(dailySales, that.dailySales) && Objects.equals(paymentMethods, that.paymentMethods) && Objects.equals(employeePerformance, that.employeePerformance);
}
@Override
public int hashCode() {
return Objects.hash(salesSummary, inventorySummary, topProducts, dailySales);
return Objects.hash(salesSummary, inventorySummary, topProducts, dailySales, paymentMethods, employeePerformance);
}
@Override
@@ -72,6 +92,8 @@ public class DashboardResponse {
", inventorySummary=" + inventorySummary +
", topProducts=" + topProducts +
", dailySales=" + dailySales +
", paymentMethods=" + paymentMethods +
", employeePerformance=" + employeePerformance +
'}';
}
@@ -80,15 +102,17 @@ public class DashboardResponse {
private Long totalSales;
private BigDecimal totalRefunds;
private Long totalRefundCount;
private Long totalItemsSold;
public SalesSummary() {
}
public SalesSummary(BigDecimal totalRevenue, Long totalSales, BigDecimal totalRefunds, Long totalRefundCount) {
public SalesSummary(BigDecimal totalRevenue, Long totalSales, BigDecimal totalRefunds, Long totalRefundCount, Long totalItemsSold) {
this.totalRevenue = totalRevenue;
this.totalSales = totalSales;
this.totalRefunds = totalRefunds;
this.totalRefundCount = totalRefundCount;
this.totalItemsSold = totalItemsSold;
}
public BigDecimal getTotalRevenue() {
@@ -123,17 +147,25 @@ public class DashboardResponse {
this.totalRefundCount = totalRefundCount;
}
public Long getTotalItemsSold() {
return totalItemsSold;
}
public void setTotalItemsSold(Long totalItemsSold) {
this.totalItemsSold = totalItemsSold;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
SalesSummary that = (SalesSummary) o;
return Objects.equals(totalRevenue, that.totalRevenue) && Objects.equals(totalSales, that.totalSales) && Objects.equals(totalRefunds, that.totalRefunds) && Objects.equals(totalRefundCount, that.totalRefundCount);
return Objects.equals(totalRevenue, that.totalRevenue) && Objects.equals(totalSales, that.totalSales) && Objects.equals(totalRefunds, that.totalRefunds) && Objects.equals(totalRefundCount, that.totalRefundCount) && Objects.equals(totalItemsSold, that.totalItemsSold);
}
@Override
public int hashCode() {
return Objects.hash(totalRevenue, totalSales, totalRefunds, totalRefundCount);
return Objects.hash(totalRevenue, totalSales, totalRefunds, totalRefundCount, totalItemsSold);
}
@Override
@@ -143,10 +175,69 @@ public class DashboardResponse {
", totalSales=" + totalSales +
", totalRefunds=" + totalRefunds +
", totalRefundCount=" + totalRefundCount +
", totalItemsSold=" + totalItemsSold +
'}';
}
}
public static class PaymentMethodData {
private String paymentMethod;
private Long count;
public PaymentMethodData() {
}
public PaymentMethodData(String paymentMethod, Long count) {
this.paymentMethod = paymentMethod;
this.count = count;
}
public String getPaymentMethod() {
return paymentMethod;
}
public void setPaymentMethod(String paymentMethod) {
this.paymentMethod = paymentMethod;
}
public Long getCount() {
return count;
}
public void setCount(Long count) {
this.count = count;
}
}
public static class EmployeePerformanceData {
private String employeeName;
private BigDecimal revenue;
public EmployeePerformanceData() {
}
public EmployeePerformanceData(String employeeName, BigDecimal revenue) {
this.employeeName = employeeName;
this.revenue = revenue;
}
public String getEmployeeName() {
return employeeName;
}
public void setEmployeeName(String employeeName) {
this.employeeName = employeeName;
}
public BigDecimal getRevenue() {
return revenue;
}
public void setRevenue(BigDecimal revenue) {
this.revenue = revenue;
}
}
public static class InventorySummary {
private Long totalProducts;
private Long lowStockProducts;

View File

@@ -0,0 +1,25 @@
package com.petshop.backend.dto.chat;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
public class UpdateConversationRequest {
@NotBlank(message = "Status is required")
@Pattern(regexp = "^(OPEN|CLOSED)$", message = "Status must be OPEN or CLOSED")
private String status;
public UpdateConversationRequest() {
}
public UpdateConversationRequest(String status) {
this.status = status;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}

View File

@@ -16,7 +16,7 @@ public interface CategoryRepository extends JpaRepository<Category, Long> {
Optional<Category> findByCategoryName(String categoryName);
@Query("SELECT c FROM Category c WHERE " +
"LOWER(c.categoryName) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
"LOWER(c.categoryType) LIKE LOWER(CONCAT('%', :q, '%'))")
Page<Category> searchCategories(@Param("q") String query, Pageable pageable);
"(:q IS NULL OR LOWER(c.categoryName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(c.categoryType) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
"(:type IS NULL OR LOWER(c.categoryType) = LOWER(:type))")
Page<Category> searchCategories(@Param("q") String query, @Param("type") String type, Pageable pageable);
}

View File

@@ -12,8 +12,8 @@ import org.springframework.stereotype.Repository;
public interface PetRepository extends JpaRepository<Pet, Long> {
@Query("SELECT p FROM Pet p WHERE " +
"LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
"LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
"LOWER(p.petBreed) LIKE LOWER(CONCAT('%', :q, '%'))")
Page<Pet> searchPets(@Param("q") String query, Pageable pageable);
"(: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);
}

View File

@@ -12,7 +12,7 @@ import org.springframework.stereotype.Repository;
public interface ProductRepository extends JpaRepository<Product, Long> {
@Query("SELECT p FROM Product p WHERE " +
"LOWER(p.prodName) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
"LOWER(p.prodDesc) LIKE LOWER(CONCAT('%', :q, '%'))")
Page<Product> searchProducts(@Param("q") String query, Pageable pageable);
"(:q IS NULL OR LOWER(p.prodName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.prodDesc, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
"(:categoryId IS NULL OR p.category.categoryId = :categoryId)")
Page<Product> searchProducts(@Param("q") String query, @Param("categoryId") Long categoryId, Pageable pageable);
}

View File

@@ -1,9 +1,12 @@
package com.petshop.backend.service;
import com.petshop.backend.dto.analytics.DashboardResponse;
import com.petshop.backend.entity.Employee;
import com.petshop.backend.entity.Inventory;
import com.petshop.backend.entity.Product;
import com.petshop.backend.entity.Sale;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.EmployeeRepository;
import com.petshop.backend.repository.InventoryRepository;
import com.petshop.backend.repository.ProductRepository;
import com.petshop.backend.repository.SaleRepository;
@@ -23,28 +26,33 @@ public class AnalyticsService {
private final SaleRepository saleRepository;
private final InventoryRepository inventoryRepository;
private final ProductRepository productRepository;
private final EmployeeRepository employeeRepository;
public AnalyticsService(SaleRepository saleRepository,
InventoryRepository inventoryRepository, ProductRepository productRepository) {
InventoryRepository inventoryRepository, ProductRepository productRepository, EmployeeRepository employeeRepository) {
this.saleRepository = saleRepository;
this.inventoryRepository = inventoryRepository;
this.productRepository = productRepository;
this.employeeRepository = employeeRepository;
}
@Transactional(readOnly = true)
public DashboardResponse getDashboardData(int days, int top) {
public DashboardResponse getDashboardData(int days, int top, User user) {
LocalDateTime startDate = LocalDateTime.now().minusDays(days);
List<Sale> sales = saleRepository.findAll().stream()
.filter(sale -> sale.getSaleDate().isAfter(startDate))
.filter(sale -> includeSaleForUser(sale, user))
.collect(Collectors.toList());
DashboardResponse.SalesSummary salesSummary = calculateSalesSummary(sales);
DashboardResponse.InventorySummary inventorySummary = calculateInventorySummary();
DashboardResponse.InventorySummary inventorySummary = user.getRole() == User.Role.ADMIN ? calculateInventorySummary() : null;
List<DashboardResponse.TopProduct> topProducts = calculateTopProducts(sales, top);
List<DashboardResponse.DailySales> dailySales = calculateDailySales(sales, days);
List<DashboardResponse.PaymentMethodData> paymentMethods = calculatePaymentMethods(sales);
List<DashboardResponse.EmployeePerformanceData> employeePerformance = calculateEmployeePerformance(sales, user);
return new DashboardResponse(salesSummary, inventorySummary, topProducts, dailySales);
return new DashboardResponse(salesSummary, inventorySummary, topProducts, dailySales, paymentMethods, employeePerformance);
}
private DashboardResponse.SalesSummary calculateSalesSummary(List<Sale> sales) {
@@ -66,7 +74,13 @@ public class AnalyticsService {
.filter(Sale::getIsRefund)
.count();
return new DashboardResponse.SalesSummary(totalRevenue, totalSales, totalRefunds, totalRefundCount);
Long totalItemsSold = sales.stream()
.filter(sale -> !sale.getIsRefund())
.flatMap(sale -> sale.getItems().stream())
.mapToLong(item -> item.getQuantity())
.sum();
return new DashboardResponse.SalesSummary(totalRevenue, totalSales, totalRefunds, totalRefundCount, totalItemsSold);
}
private DashboardResponse.InventorySummary calculateInventorySummary() {
@@ -93,6 +107,9 @@ public class AnalyticsService {
Map<Long, DashboardResponse.TopProduct> productSalesMap = new HashMap<>();
for (Sale sale : sales) {
if (sale.getIsRefund()) {
continue;
}
for (var item : sale.getItems()) {
Long productId = item.getProduct().getProdId();
String productName = item.getProduct().getProdName();
@@ -128,6 +145,9 @@ public class AnalyticsService {
}
for (Sale sale : sales) {
if (sale.getIsRefund()) {
continue;
}
LocalDate saleDate = sale.getSaleDate().toLocalDate();
if (dailySalesMap.containsKey(saleDate)) {
DashboardResponse.DailySales dailySale = dailySalesMap.get(saleDate);
@@ -138,4 +158,50 @@ public class AnalyticsService {
return new ArrayList<>(dailySalesMap.values());
}
private List<DashboardResponse.PaymentMethodData> calculatePaymentMethods(List<Sale> sales) {
return sales.stream()
.filter(sale -> !sale.getIsRefund())
.collect(Collectors.groupingBy(
sale -> sale.getPaymentMethod() == null ? "Unknown" : sale.getPaymentMethod(),
TreeMap::new,
Collectors.counting()))
.entrySet().stream()
.map(entry -> new DashboardResponse.PaymentMethodData(entry.getKey(), entry.getValue()))
.collect(Collectors.toList());
}
private List<DashboardResponse.EmployeePerformanceData> calculateEmployeePerformance(List<Sale> sales, User user) {
Map<String, BigDecimal> employeeRevenue = new TreeMap<>();
for (Sale sale : sales) {
if (sale.getIsRefund()) {
continue;
}
String employeeName = sale.getEmployee().getFirstName() + " " + sale.getEmployee().getLastName();
employeeRevenue.merge(employeeName, sale.getTotalAmount(), BigDecimal::add);
}
if (user.getRole() == User.Role.STAFF && employeeRevenue.isEmpty()) {
Employee employee = employeeRepository.findByUserId(user.getId()).orElse(null);
if (employee != null) {
String employeeName = employee.getFirstName() + " " + employee.getLastName();
employeeRevenue.put(employeeName, BigDecimal.ZERO);
}
}
return employeeRevenue.entrySet().stream()
.map(entry -> new DashboardResponse.EmployeePerformanceData(entry.getKey(), entry.getValue()))
.collect(Collectors.toList());
}
private boolean includeSaleForUser(Sale sale, User user) {
if (user.getRole() == User.Role.ADMIN) {
return true;
}
if (user.getRole() == User.Role.STAFF) {
return sale.getEmployee() != null && sale.getEmployee().getUserId() != null && sale.getEmployee().getUserId().equals(user.getId());
}
return false;
}
}

View File

@@ -193,6 +193,7 @@ public class AppointmentService {
}
@Transactional(readOnly = true)
public List<String> checkAvailability(Long storeId, Long serviceId, LocalDate date) {
storeRepository.findById(storeId)
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + storeId));
@@ -200,7 +201,16 @@ public class AppointmentService {
com.petshop.backend.entity.Service service = serviceRepository.findById(serviceId)
.orElseThrow(() -> new ResourceNotFoundException("Service not found with id: " + serviceId));
List<Appointment> existingAppointments = appointmentRepository.findByStoreAndDate(storeId, date);
//--------------------------------------------------------------------------
// CHANGED: filter by serviceId too
List<Appointment> existingAppointments = appointmentRepository
.findByStoreAndDate(storeId, date)
.stream()
.filter(a -> a.getService().getServiceId().equals(serviceId))
.collect(Collectors.toList());
// -------------------------------------------------------
List<String> availableSlots = new ArrayList<>();
LocalTime startTime = LocalTime.of(9, 0);
@@ -286,13 +296,21 @@ public class AppointmentService {
return response;
}
//------------------------------------
private void validateAvailability(StoreLocation store, com.petshop.backend.entity.Service service, LocalDate date, LocalTime time, Long appointmentIdToIgnore) {
List<Appointment> existingAppointments = appointmentRepository.findByStoreAndDate(store.getStoreId(), date);
// Filter by same service only - different services can run at same time
List<Appointment> existingAppointments = appointmentRepository
.findByStoreAndDate(store.getStoreId(), date)
.stream()
.filter(a -> a.getService().getServiceId().equals(service.getServiceId()))
.collect(Collectors.toList());
if (!isSlotAvailable(existingAppointments, service, time, appointmentIdToIgnore)) {
throw new IllegalArgumentException("Appointment time is not available for the selected store and service");
}
}
//------------------------------------------------
private boolean isSlotAvailable(List<Appointment> existingAppointments, com.petshop.backend.entity.Service requestedService, LocalTime requestedStart, Long appointmentIdToIgnore) {
LocalTime requestedEnd = requestedStart.plusMinutes(requestedService.getServiceDuration());
for (Appointment existingAppointment : existingAppointments) {

View File

@@ -20,14 +20,9 @@ public class CategoryService {
this.categoryRepository = categoryRepository;
}
public Page<CategoryResponse> getAllCategories(String query, Pageable pageable) {
Page<Category> categories;
if (query != null && !query.trim().isEmpty()) {
categories = categoryRepository.searchCategories(query, pageable);
} else {
categories = categoryRepository.findAll(pageable);
}
return categories.map(this::mapToResponse);
public Page<CategoryResponse> getAllCategories(String query, String type, Pageable pageable) {
return categoryRepository.searchCategories(normalizeFilter(query), normalizeFilter(type), pageable)
.map(this::mapToResponse);
}
public CategoryResponse getCategoryById(Long id) {
@@ -80,4 +75,12 @@ public class CategoryService {
category.getUpdatedAt()
);
}
private String normalizeFilter(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}

View File

@@ -4,6 +4,7 @@ import com.petshop.backend.dto.chat.ConversationRequest;
import com.petshop.backend.dto.chat.ConversationResponse;
import com.petshop.backend.dto.chat.MessageRequest;
import com.petshop.backend.dto.chat.MessageResponse;
import com.petshop.backend.dto.chat.UpdateConversationRequest;
import com.petshop.backend.entity.Conversation;
import com.petshop.backend.entity.Customer;
import com.petshop.backend.entity.Message;
@@ -116,6 +117,10 @@ public class ChatService {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
if (conversation.getStatus() == Conversation.ConversationStatus.CLOSED) {
throw new AccessDeniedException("Conversation is closed");
}
if (!hasConversationAccess(conversation, userId, role)) {
if (role == User.Role.CUSTOMER) {
throw new AccessDeniedException("You can only send messages to your own conversations");
@@ -149,6 +154,10 @@ public class ChatService {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
if (conversation.getStatus() == Conversation.ConversationStatus.CLOSED) {
throw new AccessDeniedException("Conversation is closed");
}
if (role != User.Role.CUSTOMER || !hasConversationAccess(conversation, userId, role)) {
throw new AccessDeniedException("You can only request human takeover for your own conversations");
}
@@ -163,6 +172,28 @@ public class ChatService {
return ConversationResponse.fromEntity(conversation, lastMessage);
}
@Transactional
public ConversationResponse updateConversation(Long conversationId, Long userId, User.Role role, UpdateConversationRequest request) {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
if (!hasConversationAccess(conversation, userId, role)) {
if (role == User.Role.CUSTOMER) {
throw new AccessDeniedException("You can only close your own conversations");
}
if (role == User.Role.STAFF) {
throw new AccessDeniedException("You can only close conversations assigned to you or unassigned conversations");
}
}
conversation.setStatus(Conversation.ConversationStatus.valueOf(request.getStatus()));
conversation = conversationRepository.save(conversation);
List<Message> messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId);
String lastMessage = messages.isEmpty() ? "" : messages.get(messages.size() - 1).getContent();
return ConversationResponse.fromEntity(conversation, lastMessage);
}
public List<MessageResponse> getMessages(Long conversationId, Long userId, User.Role role) {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));

View File

@@ -4,23 +4,34 @@ import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.dto.customer.CustomerRequest;
import com.petshop.backend.dto.customer.CustomerResponse;
import com.petshop.backend.entity.Customer;
import com.petshop.backend.entity.User;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.CustomerRepository;
import com.petshop.backend.repository.UserRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import static org.springframework.http.HttpStatus.CONFLICT;
@Service
public class CustomerService {
private static final String TEMP_PASSWORD = "TempPass123!";
private final CustomerRepository customerRepository;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final UserBusinessLinkageService userBusinessLinkageService;
public CustomerService(CustomerRepository customerRepository, UserRepository userRepository) {
public CustomerService(CustomerRepository customerRepository, UserRepository userRepository, PasswordEncoder passwordEncoder, UserBusinessLinkageService userBusinessLinkageService) {
this.customerRepository = customerRepository;
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.userBusinessLinkageService = userBusinessLinkageService;
}
public Page<CustomerResponse> getAllCustomers(String query, Pageable pageable) {
@@ -41,14 +52,19 @@ public class CustomerService {
@Transactional
public CustomerResponse createCustomer(CustomerRequest request) {
ensureEmailAvailable(request.getEmail(), null);
Customer customer = new Customer();
customer.setFirstName(request.getFirstName());
customer.setLastName(request.getLastName());
customer.setEmail(request.getEmail());
customer = customerRepository.save(customer);
syncLinkedUser(customer);
return mapToResponse(customer);
User user = createLinkedUser(customer);
Customer linkedCustomer = userBusinessLinkageService.ensureLinkedCustomer(user);
syncLinkedUser(linkedCustomer);
return mapToResponse(linkedCustomer);
}
@Transactional
@@ -56,6 +72,8 @@ public class CustomerService {
Customer customer = customerRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Customer not found with id: " + id));
ensureEmailAvailable(request.getEmail(), customer.getUserId());
customer.setFirstName(request.getFirstName());
customer.setLastName(request.getLastName());
customer.setEmail(request.getEmail());
@@ -67,9 +85,14 @@ public class CustomerService {
@Transactional
public void deleteCustomer(Long id) {
if (!customerRepository.existsById(id)) {
throw new ResourceNotFoundException("Customer not found with id: " + id);
Customer customer = customerRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Customer not found with id: " + id));
if (customer.getUserId() != null && userRepository.existsById(customer.getUserId())) {
userRepository.deleteById(customer.getUserId());
return;
}
customerRepository.deleteById(id);
}
@@ -99,4 +122,37 @@ public class CustomerService {
userRepository.save(user);
});
}
private User createLinkedUser(Customer customer) {
User user = new User();
user.setUsername(generateUsername(customer));
user.setPassword(passwordEncoder.encode(TEMP_PASSWORD));
user.setEmail(customer.getEmail());
user.setFullName((customer.getFirstName() + " " + customer.getLastName()).trim());
user.setPhone(generatePhone(customer));
user.setRole(User.Role.CUSTOMER);
user.setActive(false);
user.setTokenVersion(0);
return userRepository.save(user);
}
private String generateUsername(Customer customer) {
return "customer_" + customer.getCustomerId();
}
private String generatePhone(Customer customer) {
return String.format("200-000-%04d", customer.getCustomerId());
}
private void ensureEmailAvailable(String email, Long currentUserId) {
if (email == null || email.isBlank()) {
return;
}
userRepository.findByEmail(email).ifPresent(existing -> {
if (currentUserId == null || !existing.getId().equals(currentUserId)) {
throw new ResponseStatusException(CONFLICT, "Email already exists");
}
});
}
}

View File

@@ -33,14 +33,9 @@ public class PetService {
this.catalogImageStorageService = catalogImageStorageService;
}
public Page<PetResponse> getAllPets(String query, Pageable pageable) {
Page<Pet> pets;
if (query != null && !query.trim().isEmpty()) {
pets = petRepository.searchPets(query, pageable);
} else {
pets = petRepository.findAll(pageable);
}
return pets.map(this::mapToResponse);
public Page<PetResponse> getAllPets(String query, String species, String status, Pageable pageable) {
return petRepository.searchPets(normalizeFilter(query), normalizeFilter(species), normalizeFilter(status), pageable)
.map(this::mapToResponse);
}
public PetResponse getPetById(Long id) {
@@ -182,6 +177,14 @@ public class PetService {
return status == null ? "" : status.trim();
}
private String normalizeFilter(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private PetResponse mapToResponse(Pet pet) {
return new PetResponse(
pet.getPetId(),

View File

@@ -32,14 +32,9 @@ public class ProductService {
this.catalogImageStorageService = catalogImageStorageService;
}
public Page<ProductResponse> getAllProducts(String query, Pageable pageable) {
Page<Product> products;
if (query != null && !query.trim().isEmpty()) {
products = productRepository.searchProducts(query, pageable);
} else {
products = productRepository.findAll(pageable);
}
return products.map(this::mapToResponse);
public Page<ProductResponse> getAllProducts(String query, Long categoryId, Pageable pageable) {
return productRepository.searchProducts(normalizeFilter(query), categoryId, pageable)
.map(this::mapToResponse);
}
public ProductResponse getProductById(Long id) {
@@ -168,4 +163,12 @@ public class ProductService {
public record ImagePayload(Resource resource, MediaType mediaType) {
}
private String normalizeFilter(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}

View File

@@ -78,7 +78,7 @@ public class SaleService {
sale.setSaleDate(LocalDateTime.now());
sale.setEmployee(employee);
sale.setStore(store);
sale.setPaymentMethod(request.getPaymentMethod());
sale.setPaymentMethod(normalizePaymentMethod(request.getPaymentMethod()));
sale.setIsRefund(request.getIsRefund() != null ? request.getIsRefund() : false);
if (request.getCustomerId() != null) {
@@ -215,4 +215,22 @@ public class SaleService {
return response;
}
String normalizePaymentMethod(String paymentMethod) {
if (paymentMethod == null) {
return null;
}
String normalized = paymentMethod.trim();
if (normalized.equalsIgnoreCase("Debit")) {
return "Card";
}
if (normalized.equalsIgnoreCase("Cash")) {
return "Cash";
}
if (normalized.equalsIgnoreCase("Card")) {
return "Card";
}
return normalized;
}
}

View File

@@ -36,6 +36,7 @@ spring:
server:
port: ${SERVER_PORT:8080}
address: ${SERVER_ADDRESS:0.0.0.0}
servlet:
context-path: /

View File

@@ -0,0 +1,3 @@
UPDATE sale
SET paymentMethod = 'Card'
WHERE LOWER(paymentMethod) = 'debit';

View File

@@ -128,7 +128,7 @@ VALUES
('2026-01-05 09:15:00', 125.00, 'Card', 1, 1, 1),
('2026-01-08 11:30:00', 200.00, 'Card', 2, 1, 2),
('2026-01-12 14:20:00', 60.00, 'Cash', 3, 2, 3),
('2026-01-15 10:45:00', 150.00, 'Debit', 1, 1, 1),
('2026-01-15 10:45:00', 150.00, 'Card', 1, 1, 1),
('2026-01-18 16:30:00', 80.00, 'Card', 4, 3, 2),
('2026-01-22 13:15:00', 95.00, 'Cash', 2, 2, NULL),
('2026-01-25 15:40:00', 240.00, 'Card', 5, 4, 4),
@@ -136,12 +136,12 @@ VALUES
('2026-02-01 09:00:00', 175.00, 'Card', 3, 3, 1),
('2026-02-03 11:20:00', 120.00, 'Card', 2, 1, 3),
('2026-02-05 14:50:00', 45.00, 'Cash', 4, 2, NULL),
('2026-02-08 16:15:00', 160.00, 'Debit', 1, 1, 2),
('2026-02-08 16:15:00', 160.00, 'Card', 1, 1, 2),
('2026-02-10 10:25:00', 100.00, 'Card', 5, 4, NULL),
('2026-02-12 13:45:00', 50.00, 'Cash', 2, 2, 1),
('2026-02-15 15:30:00', 85.00, 'Card', 3, 3, NULL),
('2026-02-18 11:10:00', 200.00, 'Card', 1, 1, 4),
('2026-02-20 14:35:00', 155.00, 'Debit', 4, 3, NULL),
('2026-02-20 14:35:00', 155.00, 'Card', 4, 3, NULL),
('2026-02-22 16:50:00', 75.00, 'Cash', 2, 1, 2),
('2026-02-24 10:15:00', 140.00, 'Card', 5, 4, NULL),
(NOW(), 95.00, 'Card', 1, 1, 1);

View File

@@ -0,0 +1,91 @@
INSERT INTO users (username, password, email, fullName, phone, role, active, tokenVersion)
SELECT
CONCAT('customer_', c.customerId) AS username,
'$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq' AS password,
CASE
WHEN c.email IS NOT NULL
AND c.email <> ''
AND (SELECT COUNT(*) FROM customer c2 WHERE c2.email = c.email) = 1
AND NOT EXISTS (SELECT 1 FROM employee e2 WHERE e2.email = c.email)
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.email = c.email)
THEN c.email
ELSE CONCAT('customer_', c.customerId, '@petshop.local')
END AS email,
CONCAT(c.firstName, ' ', c.lastName) AS fullName,
CONCAT('200-000-', LPAD(c.customerId, 4, '0')) AS phone,
'CUSTOMER' AS role,
FALSE AS active,
0 AS tokenVersion
FROM customer c
WHERE c.user_id IS NULL
AND NOT EXISTS (
SELECT 1
FROM users u
WHERE u.username = CONCAT('customer_', c.customerId)
);
INSERT INTO users (username, password, email, fullName, phone, role, active, tokenVersion)
SELECT
CONCAT('employee_', e.employeeId) AS username,
'$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq' AS password,
CASE
WHEN e.email IS NOT NULL
AND e.email <> ''
AND (SELECT COUNT(*) FROM employee e2 WHERE e2.email = e.email) = 1
AND NOT EXISTS (SELECT 1 FROM customer c2 WHERE c2.email = e.email)
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.email = e.email)
THEN e.email
ELSE CONCAT('employee_', e.employeeId, '@petshop.local')
END AS email,
CONCAT(e.firstName, ' ', e.lastName) AS fullName,
CONCAT('300-000-', LPAD(e.employeeId, 4, '0')) AS phone,
CASE
WHEN UPPER(e.role) = 'MANAGER' THEN 'ADMIN'
ELSE 'STAFF'
END AS role,
FALSE AS active,
0 AS tokenVersion
FROM employee e
WHERE e.user_id IS NULL
AND NOT EXISTS (
SELECT 1
FROM users u
WHERE u.username = CONCAT('employee_', e.employeeId)
);
UPDATE customer c
JOIN users u ON u.username = CONCAT('customer_', c.customerId)
AND u.role = 'CUSTOMER'
SET c.user_id = u.id
WHERE c.user_id IS NULL;
UPDATE employee e
JOIN users u ON u.username = CONCAT('employee_', e.employeeId)
AND u.role IN ('STAFF', 'ADMIN')
SET e.user_id = u.id
WHERE e.user_id IS NULL;
UPDATE users
SET
fullName = CASE
WHEN fullName IS NULL OR fullName = '' THEN username
ELSE fullName
END,
email = CASE
WHEN email IS NULL OR email = '' THEN CONCAT(username, '@petshop.local')
ELSE email
END,
phone = CASE
WHEN phone IS NULL OR phone = '' THEN CONCAT('000-000-', LPAD(id, 4, '0'))
ELSE phone
END,
active = COALESCE(active, TRUE),
tokenVersion = COALESCE(tokenVersion, 0)
WHERE fullName IS NULL
OR fullName = ''
OR email IS NULL
OR email = ''
OR phone IS NULL
OR phone = ''
OR active IS NULL
OR tokenVersion IS NULL;

View File

@@ -0,0 +1,226 @@
-- Expand pet and product seed data
INSERT INTO pet (petName, petSpecies, petBreed, petAge, petStatus, petPrice)
VALUES
('Rocky', 'Dog', 'German Shepherd', 1, 'Available', 475.00),
('Daisy', 'Dog', 'Poodle', 2, 'Available', 512.00),
('Cooper', 'Dog', 'Bulldog', 3, 'Available', 560.00),
('Ruby', 'Dog', 'Boxer', 4, 'Available', 575.00),
('Tucker', 'Dog', 'Dachshund', 5, 'Available', 634.00),
('Rosie', 'Dog', 'Shih Tzu', 1, 'Available', 660.00),
('Bear', 'Dog', 'Rottweiler', 2, 'Available', 686.00),
('Maggie', 'Dog', 'Corgi', 3, 'Available', 745.00),
('Leo', 'Dog', 'Husky', 4, 'Available', 749.00),
('Penny', 'Dog', 'Border Collie', 5, 'Available', 808.00),
('Jax', 'Dog', 'German Shepherd', 1, 'Available', 823.00),
('Nala', 'Dog', 'Poodle', 2, 'Available', 871.00),
('Finn', 'Dog', 'Bulldog', 3, 'Available', 447.00),
('Sadie', 'Dog', 'Boxer', 4, 'Available', 495.00),
('Ace', 'Dog', 'Dachshund', 5, 'Available', 510.00),
('Zoe', 'Dog', 'Shih Tzu', 1, 'Available', 547.00),
('Ollie', 'Dog', 'Rottweiler', 2, 'Available', 606.00),
('Millie', 'Dog', 'Corgi', 3, 'Available', 654.00),
('Murphy', 'Dog', 'Husky', 4, 'Available', 691.00),
('Willow', 'Dog', 'Border Collie', 5, 'Available', 728.00),
('Bentley', 'Dog', 'German Shepherd', 1, 'Available', 776.00),
('Lily', 'Dog', 'Poodle', 2, 'Available', 780.00),
('Scout', 'Dog', 'Bulldog', 3, 'Available', 828.00),
('Gracie', 'Dog', 'Boxer', 4, 'Available', 876.00),
('Ranger', 'Dog', 'Dachshund', 5, 'Available', 452.00),
('Hazel', 'Dog', 'Shih Tzu', 1, 'Available', 478.00),
('Moose', 'Dog', 'Rottweiler', 2, 'Available', 515.00),
('Mia', 'Dog', 'Corgi', 3, 'Available', 530.00),
('Simba', 'Cat', 'Ragdoll', 1, 'Available', 295.00),
('Cleo', 'Cat', 'Bengal', 2, 'Available', 321.00),
('Oreo', 'Cat', 'British Shorthair', 3, 'Available', 358.00),
('Pepper', 'Cat', 'Sphynx', 4, 'Available', 417.00),
('Jasper', 'Cat', 'Scottish Fold', 5, 'Available', 454.00),
('Phoebe', 'Cat', 'Russian Blue', 1, 'Available', 491.00),
('Shadow', 'Cat', 'Abyssinian', 2, 'Available', 528.00),
('Mochi', 'Cat', 'Birman', 3, 'Available', 554.00),
('Louie', 'Cat', 'Ragdoll', 4, 'Available', 591.00),
('Ivy', 'Cat', 'Bengal', 5, 'Available', 606.00),
('Theo', 'Cat', 'British Shorthair', 1, 'Available', 654.00),
('Piper', 'Cat', 'Sphynx', 2, 'Available', 251.00),
('Nova', 'Cat', 'Scottish Fold', 3, 'Available', 277.00),
('Archie', 'Cat', 'Russian Blue', 4, 'Available', 336.00),
('Olive', 'Cat', 'Abyssinian', 5, 'Available', 362.00),
('Boots', 'Cat', 'Birman', 1, 'Available', 399.00),
('Maple', 'Cat', 'Ragdoll', 2, 'Available', 436.00),
('Gizmo', 'Cat', 'Bengal', 3, 'Available', 473.00),
('Nina', 'Cat', 'British Shorthair', 4, 'Available', 499.00),
('Salem', 'Cat', 'Sphynx', 5, 'Available', 547.00),
('Stella', 'Cat', 'Scottish Fold', 1, 'Available', 595.00),
('Kiki', 'Cat', 'Russian Blue', 2, 'Available', 610.00),
('Sunny', 'Cat', 'Abyssinian', 3, 'Available', 658.00),
('Mabel', 'Cat', 'Birman', 4, 'Available', 244.00),
('Coco', 'Bird', 'Cockatiel', 1, 'Available', 119.00),
('Sky', 'Bird', 'Parakeet', 2, 'Available', 145.00),
('Sunny', 'Bird', 'Canary', 3, 'Available', 204.00),
('Kiwi', 'Bird', 'Lovebird', 1, 'Available', 230.00),
('Pico', 'Bird', 'Finch', 2, 'Available', 81.00),
('Blue', 'Bird', 'Conure', 3, 'Available', 118.00),
('Rio', 'Bird', 'Cockatiel', 1, 'Available', 144.00),
('Angel', 'Bird', 'Parakeet', 2, 'Available', 203.00),
('Chirpy', 'Bird', 'Canary', 3, 'Available', 251.00),
('Peach', 'Bird', 'Lovebird', 1, 'Available', 91.00),
('Mango', 'Bird', 'Finch', 2, 'Available', 128.00),
('Pearl', 'Bird', 'Conure', 3, 'Available', 165.00),
('Bubbles', 'Fish', 'Goldfish', 1, 'Available', 30.00),
('Splash', 'Fish', 'Betta', 2, 'Available', 56.00),
('Coral', 'Fish', 'Guppy', 1, 'Available', 23.00),
('Neptune', 'Fish', 'Molly', 2, 'Available', 23.00),
('Marlin', 'Fish', 'Tetra', 1, 'Available', 49.00),
('Finley', 'Fish', 'Angelfish', 2, 'Available', 27.00),
('Pebble', 'Fish', 'Goldfish', 1, 'Available', 64.00),
('Wave', 'Fish', 'Betta', 2, 'Available', 20.00),
('Aqua', 'Fish', 'Guppy', 1, 'Available', 57.00),
('Flash', 'Fish', 'Molly', 2, 'Available', 46.00),
('Nemo', 'Fish', 'Tetra', 1, 'Available', 13.00),
('Pearl', 'Fish', 'Angelfish', 2, 'Available', 61.00),
('Thumper', 'Rabbit', 'Mini Lop', 1, 'Adopted', 147.00),
('Clover', 'Rabbit', 'Netherland Dwarf', 2, 'Adopted', 138.00),
('Biscuit', 'Rabbit', 'Lionhead', 3, 'Adopted', 177.00),
('Hazel', 'Rabbit', 'Rex', 1, 'Adopted', 91.00),
('Juniper', 'Rabbit', 'Mini Lop', 2, 'Adopted', 83.00),
('Poppy', 'Rabbit', 'Netherland Dwarf', 3, 'Adopted', 111.00),
('Snowball', 'Rabbit', 'Lionhead', 1, 'Adopted', 172.00),
('Maple', 'Rabbit', 'Rex', 2, 'Adopted', 150.00),
('Peanut', 'Hamster', 'Syrian', 1, 'Adopted', 29.00),
('Nibbles', 'Hamster', 'Dwarf', 2, 'Adopted', 42.00),
('Pumpkin', 'Hamster', 'Roborovski', 1, 'Pending', 49.00),
('Mocha', 'Hamster', 'Syrian', 2, 'Pending', 48.00),
('Buttons', 'Hamster', 'Dwarf', 1, 'Pending', 61.00),
('Teddy', 'Hamster', 'Roborovski', 2, 'Pending', 35.00),
('Pip', 'Hamster', 'Syrian', 1, 'Pending', 39.00),
('Toffee', 'Hamster', 'Dwarf', 2, 'Pending', 52.00),
('Sprout', 'Hamster', 'Roborovski', 1, 'Available', 26.00),
('Bean', 'Hamster', 'Syrian', 2, 'Available', 28.00);
INSERT INTO product (prodName, prodPrice, categoryId, prodDesc)
VALUES
('Chicken Recipe Dog Food', 42.00, 1, 'Nutritious food and treats for dogs'),
('Beef Feast Dog Food', 51.00, 1, 'Nutritious food and treats for dogs'),
('Salmon Blend Dog Food', 17.00, 1, 'Nutritious food and treats for dogs'),
('Lamb Dinner Dog Food', 28.00, 1, 'Nutritious food and treats for dogs'),
('Puppy Starter Kibble', 39.00, 1, 'Nutritious food and treats for dogs'),
('Senior Care Dog Food', 40.00, 1, 'Nutritious food and treats for dogs'),
('Small Breed Kibble', 44.00, 1, 'Nutritious food and treats for dogs'),
('Large Breed Kibble', 57.00, 1, 'Nutritious food and treats for dogs'),
('Grain Free Dog Food', 68.00, 1, 'Nutritious food and treats for dogs'),
('Turkey Rice Formula', 79.00, 1, 'Nutritious food and treats for dogs'),
('Duck Sweet Potato Meal', 25.00, 1, 'Nutritious food and treats for dogs'),
('Venison Protein Blend', 36.00, 1, 'Nutritious food and treats for dogs'),
('Healthy Weight Dog Food', 48.00, 1, 'Nutritious food and treats for dogs'),
('Sensitive Stomach Kibble', 62.00, 1, 'Nutritious food and treats for dogs'),
('High Energy Dog Food', 72.00, 1, 'Nutritious food and treats for dogs'),
('Organic Dog Biscuits', 18.00, 1, 'Nutritious food and treats for dogs'),
('Peanut Butter Dog Treats', 33.00, 1, 'Nutritious food and treats for dogs'),
('Dental Chew Sticks', 38.00, 1, 'Nutritious food and treats for dogs'),
('Training Treat Bites', 48.00, 1, 'Nutritious food and treats for dogs'),
('Soft Chicken Treats', 57.00, 1, 'Nutritious food and treats for dogs'),
('Pumpkin Fiber Treats', 70.00, 1, 'Nutritious food and treats for dogs'),
('Joint Support Biscuits', 14.00, 1, 'Nutritious food and treats for dogs'),
('Mini Breed Dinner', 17.00, 1, 'Nutritious food and treats for dogs'),
('Farmhouse Dog Meal', 30.00, 1, 'Nutritious food and treats for dogs'),
('Feather Teaser Wand', 30.00, 2, 'Play items for active cats'),
('Catnip Mouse Toy', 24.00, 2, 'Play items for active cats'),
('Jingle Ball Set', 18.00, 2, 'Play items for active cats'),
('Scratching Post Small', 6.00, 2, 'Play items for active cats'),
('Crinkle Tunnel', 31.00, 2, 'Play items for active cats'),
('Laser Pointer Toy', 6.00, 2, 'Play items for active cats'),
('Plush Fish Toy', 19.00, 2, 'Play items for active cats'),
('Spring Coil Pack', 20.00, 2, 'Play items for active cats'),
('Hanging Door Toy', 12.00, 2, 'Play items for active cats'),
('Interactive Puzzle Toy', 22.00, 2, 'Play items for active cats'),
('Catnip Kicker Toy', 20.00, 2, 'Play items for active cats'),
('Rolling Bell Ball', 20.00, 2, 'Play items for active cats'),
('Ribbon Chase Toy', 19.00, 2, 'Play items for active cats'),
('Mini Plush Mouse', 21.00, 2, 'Play items for active cats'),
('Treat Dispensing Ball', 16.00, 2, 'Play items for active cats'),
('Double Pom Toy', 12.00, 2, 'Play items for active cats'),
('Window Perch Toy', 10.00, 2, 'Play items for active cats'),
('Scratch Pad Refill', 8.00, 2, 'Play items for active cats'),
('Rainbow Wand Toy', 23.00, 2, 'Play items for active cats'),
('Carpet Scratcher', 23.00, 2, 'Play items for active cats'),
('Bird Perch Set', 27.00, 3, 'Care supplies for pet birds'),
('Parakeet Seed Mix', 40.00, 3, 'Care supplies for pet birds'),
('Canary Food Blend', 53.00, 3, 'Care supplies for pet birds'),
('Mineral Cuttlebone', 57.00, 3, 'Care supplies for pet birds'),
('Bird Ladder Toy', 68.00, 3, 'Care supplies for pet birds'),
('Mirror Bell Combo', 80.00, 3, 'Care supplies for pet birds'),
('Clip On Food Cup', 92.00, 3, 'Care supplies for pet birds'),
('Bird Cage Liner Pack', 108.00, 3, 'Care supplies for pet birds'),
('Nesting Material Pack', 121.00, 3, 'Care supplies for pet birds'),
('Treat Spray Millet', 8.00, 3, 'Care supplies for pet birds'),
('Wooden Swing Perch', 22.00, 3, 'Care supplies for pet birds'),
('Foraging Ball Toy', 32.00, 3, 'Care supplies for pet birds'),
('Cage Cleaning Spray', 47.00, 3, 'Care supplies for pet birds'),
('Parrot Rope Perch', 54.00, 3, 'Care supplies for pet birds'),
('Bird Bath Dish', 54.00, 3, 'Care supplies for pet birds'),
('Songbird Vitamin Drops', 78.00, 3, 'Care supplies for pet birds'),
('Aquarium Filter Cartridge', 36.00, 4, 'Essential aquarium equipment and accessories'),
('Decorative Aquarium Gravel', 49.00, 4, 'Essential aquarium equipment and accessories'),
('Fish Net Medium', 34.00, 4, 'Essential aquarium equipment and accessories'),
('Water Conditioner', 45.00, 4, 'Essential aquarium equipment and accessories'),
('Aquarium Thermometer', 59.00, 4, 'Essential aquarium equipment and accessories'),
('LED Tank Light', 67.00, 4, 'Essential aquarium equipment and accessories'),
('Air Stone Pack', 76.00, 4, 'Essential aquarium equipment and accessories'),
('Aquarium Heater 50W', 92.00, 4, 'Essential aquarium equipment and accessories'),
('Aquarium Heater 100W', 106.00, 4, 'Essential aquarium equipment and accessories'),
('Fish Flake Food', 95.00, 4, 'Essential aquarium equipment and accessories'),
('Algae Scraper', 105.00, 4, 'Essential aquarium equipment and accessories'),
('Aquarium Plant Set', 122.00, 4, 'Essential aquarium equipment and accessories'),
('Bubble Curtain Kit', 136.00, 4, 'Essential aquarium equipment and accessories'),
('Breeder Box Insert', 149.00, 4, 'Essential aquarium equipment and accessories'),
('Filter Sponge Pack', 164.00, 4, 'Essential aquarium equipment and accessories'),
('Aquarium Background Roll', 183.00, 4, 'Essential aquarium equipment and accessories'),
('Glass Lid Clips', 174.00, 4, 'Essential aquarium equipment and accessories'),
('Submersible Pump', 191.00, 4, 'Essential aquarium equipment and accessories'),
('Hamster Bedding Pack', 50.00, 5, 'Supplies for small pets'),
('Rabbit Hay Bundle', 49.00, 5, 'Supplies for small pets'),
('Guinea Pig Pellets', 15.00, 5, 'Supplies for small pets'),
('Small Pet Water Bottle', 31.00, 5, 'Supplies for small pets'),
('Hamster Hideout Hut', 40.00, 5, 'Supplies for small pets'),
('Chew Stick Bundle', 48.00, 5, 'Supplies for small pets'),
('Rabbit Litter Tray', 58.00, 5, 'Supplies for small pets'),
('Exercise Ball Large', 68.00, 5, 'Supplies for small pets'),
('Small Pet Food Bowl', 20.00, 5, 'Supplies for small pets'),
('Timothy Hay Cubes', 28.00, 5, 'Supplies for small pets'),
('Guinea Pig Tunnel', 38.00, 5, 'Supplies for small pets'),
('Hamster Nesting Fluff', 47.00, 5, 'Supplies for small pets'),
('Rabbit Grooming Brush', 60.00, 5, 'Supplies for small pets'),
('Small Pet Carrier', 7.00, 5, 'Supplies for small pets'),
('Hay Rack Feeder', 11.00, 5, 'Supplies for small pets'),
('Wooden Chew Blocks', 27.00, 5, 'Supplies for small pets');
INSERT INTO productSupplier (supId, prodId, cost)
SELECT CASE MOD(p.prodId - 7, 5)
WHEN 0 THEN 1
WHEN 1 THEN 2
WHEN 2 THEN 3
WHEN 3 THEN 4
ELSE 5
END,
p.prodId,
ROUND(p.prodPrice * (0.62 + (MOD(p.prodId - 7, 5) * 0.03)), 2)
FROM product p
WHERE p.prodId >= 7
AND NOT EXISTS (
SELECT 1 FROM productSupplier ps WHERE ps.prodId = p.prodId
);
INSERT INTO inventory (prodId, quantity)
SELECT p.prodId,
CASE p.categoryId
WHEN 1 THEN 120 + MOD((p.prodId - 7) * 17, 60)
WHEN 2 THEN 180 + MOD((p.prodId - 7) * 17, 60)
WHEN 3 THEN 70 + MOD((p.prodId - 7) * 17, 60)
WHEN 4 THEN 45 + MOD((p.prodId - 7) * 17, 60)
ELSE 95 + MOD((p.prodId - 7) * 17, 60)
END
FROM product p
WHERE p.prodId >= 7
AND NOT EXISTS (
SELECT 1 FROM inventory i WHERE i.prodId = p.prodId
);

View File

@@ -0,0 +1,191 @@
package com.petshop.backend.service;
import com.petshop.backend.entity.Appointment;
import com.petshop.backend.entity.Customer;
import com.petshop.backend.entity.Pet;
import com.petshop.backend.entity.Service;
import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.AppointmentRepository;
import com.petshop.backend.repository.CustomerRepository;
import com.petshop.backend.repository.PetRepository;
import com.petshop.backend.repository.ServiceRepository;
import com.petshop.backend.repository.StoreRepository;
import com.petshop.backend.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
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;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AppointmentServiceTest {
@Mock
private AppointmentRepository appointmentRepository;
@Mock
private CustomerRepository customerRepository;
@Mock
private PetRepository petRepository;
@Mock
private ServiceRepository serviceRepository;
@Mock
private StoreRepository storeRepository;
@Mock
private UserRepository userRepository;
@InjectMocks
private AppointmentService appointmentService;
private Customer customer;
private StoreLocation store;
private Service grooming;
private Service nailTrim;
private Pet pet;
private LocalDate date;
@BeforeEach
void setUp() {
customer = new Customer();
customer.setCustomerId(1L);
customer.setFirstName("Pat");
customer.setLastName("Owner");
store = new StoreLocation();
store.setStoreId(1L);
store.setStoreName("Main Store");
grooming = new Service();
grooming.setServiceId(1L);
grooming.setServiceName("Grooming");
grooming.setServiceDuration(30);
nailTrim = new Service();
nailTrim.setServiceId(2L);
nailTrim.setServiceName("Nail Trim");
nailTrim.setServiceDuration(30);
pet = new Pet();
pet.setPetId(1L);
pet.setPetName("Milo");
date = LocalDate.now().plusDays(1);
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Test
void checkAvailabilityAllowsDifferentServicesAtSameTime() {
Appointment existing = appointment(1L, date, LocalTime.of(10, 0), grooming, store);
when(storeRepository.findById(1L)).thenReturn(Optional.of(store));
when(serviceRepository.findById(2L)).thenReturn(Optional.of(nailTrim));
when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of(existing));
List<String> slots = appointmentService.checkAvailability(1L, 2L, date);
assertTrue(slots.contains("10:00"));
}
@Test
void checkAvailabilityBlocksSameServiceAtSameTime() {
Appointment existing = appointment(1L, date, LocalTime.of(10, 0), grooming, store);
when(storeRepository.findById(1L)).thenReturn(Optional.of(store));
when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming));
when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of(existing));
List<String> slots = appointmentService.checkAvailability(1L, 1L, date);
assertFalse(slots.contains("10:00"));
}
@Test
void cancelledAppointmentsDoNotBlockAvailability() {
when(storeRepository.findById(1L)).thenReturn(Optional.of(store));
when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming));
when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of());
List<String> slots = appointmentService.checkAvailability(1L, 1L, date);
assertTrue(slots.contains("10:00"));
}
@Test
void updateAppointmentDoesNotConflictWithItself() {
Appointment existing = appointment(1L, date, LocalTime.of(10, 0), grooming, store);
User user = new User();
user.setId(10L);
user.setUsername("pat");
user.setRole(User.Role.CUSTOMER);
user.setTokenVersion(0);
when(userRepository.findById(10L)).thenReturn(Optional.of(user));
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(
new com.petshop.backend.security.AppPrincipal(10L, "pat", User.Role.CUSTOMER, 0),
"n/a",
List.of(new SimpleGrantedAuthority("ROLE_CUSTOMER"))
)
);
when(appointmentRepository.findById(1L)).thenReturn(Optional.of(existing));
when(customerRepository.findById(1L)).thenReturn(Optional.of(customer));
when(storeRepository.findById(1L)).thenReturn(Optional.of(store));
when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming));
when(petRepository.findById(1L)).thenReturn(Optional.of(pet));
when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of(existing));
when(appointmentRepository.save(any(Appointment.class))).thenAnswer(invocation -> invocation.getArgument(0));
var request = new com.petshop.backend.dto.appointment.AppointmentRequest();
request.setCustomerId(1L);
request.setStoreId(1L);
request.setServiceId(1L);
request.setAppointmentDate(date);
request.setAppointmentTime(LocalTime.of(10, 0));
request.setAppointmentStatus("Booked");
request.setPetIds(List.of(1L));
var response = appointmentService.updateAppointment(1L, request);
assertEquals(1L, response.getAppointmentId());
assertEquals("Booked", response.getAppointmentStatus());
}
private Appointment appointment(Long id, LocalDate date, LocalTime time, Service service, StoreLocation storeLocation) {
Appointment appointment = new Appointment();
appointment.setAppointmentId(id);
appointment.setAppointmentDate(date);
appointment.setAppointmentTime(time);
appointment.setAppointmentStatus("Booked");
appointment.setService(service);
appointment.setStore(storeLocation);
appointment.setCustomer(customer);
appointment.setPets(Set.of());
return appointment;
}
}

View File

@@ -0,0 +1,199 @@
package com.petshop.backend.service;
import com.petshop.backend.dto.chat.MessageRequest;
import com.petshop.backend.dto.chat.UpdateConversationRequest;
import com.petshop.backend.entity.Conversation;
import com.petshop.backend.entity.Customer;
import com.petshop.backend.entity.Message;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.ConversationRepository;
import com.petshop.backend.repository.CustomerRepository;
import com.petshop.backend.repository.MessageRepository;
import com.petshop.backend.repository.UserRepository;
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.junit.jupiter.MockitoExtension;
import org.springframework.security.access.AccessDeniedException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ChatServiceTest {
@Mock
private ConversationRepository conversationRepository;
@Mock
private MessageRepository messageRepository;
@Mock
private UserRepository userRepository;
@Mock
private CustomerRepository customerRepository;
@InjectMocks
private ChatService chatService;
private Customer customer;
@BeforeEach
void setUp() {
customer = new Customer();
customer.setCustomerId(1L);
customer.setUserId(10L);
customer.setFirstName("Pat");
customer.setLastName("Owner");
customer.setEmail("pat@example.com");
}
@Test
void updateConversationMarksConversationClosed() {
Conversation conversation = conversation(99L, 1L, null, Conversation.ConversationStatus.OPEN);
when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation));
when(customerRepository.findByUserId(10L)).thenReturn(Optional.of(customer));
when(conversationRepository.save(any(Conversation.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(messageRepository.findByConversationIdOrderByTimestampAsc(99L))
.thenReturn(List.of(message("hello")));
var response = chatService.updateConversation(99L, 10L, User.Role.CUSTOMER, new UpdateConversationRequest("CLOSED"));
assertEquals("CLOSED", response.getStatus());
assertEquals("hello", response.getLastMessage());
verify(conversationRepository).save(conversation);
}
@Test
void updateConversationRejectsOtherCustomer() {
Conversation conversation = conversation(99L, 2L, null, Conversation.ConversationStatus.OPEN);
when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation));
when(customerRepository.findByUserId(10L)).thenReturn(Optional.of(customer));
assertThrows(AccessDeniedException.class,
() -> chatService.updateConversation(99L, 10L, User.Role.CUSTOMER, new UpdateConversationRequest("CLOSED")));
}
@Test
void updateConversationIsIdempotent() {
Conversation conversation = conversation(99L, 1L, null, Conversation.ConversationStatus.CLOSED);
when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation));
when(customerRepository.findByUserId(10L)).thenReturn(Optional.of(customer));
when(conversationRepository.save(any(Conversation.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(messageRepository.findByConversationIdOrderByTimestampAsc(99L)).thenReturn(List.of());
var response = chatService.updateConversation(99L, 10L, User.Role.CUSTOMER, new UpdateConversationRequest("CLOSED"));
assertEquals("CLOSED", response.getStatus());
}
@Test
void staffCanCloseAssignedConversation() {
Conversation conversation = conversation(99L, 1L, 77L, Conversation.ConversationStatus.OPEN);
when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation));
when(conversationRepository.save(any(Conversation.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(messageRepository.findByConversationIdOrderByTimestampAsc(99L)).thenReturn(List.of());
var response = chatService.updateConversation(99L, 77L, User.Role.STAFF, new UpdateConversationRequest("CLOSED"));
assertEquals("CLOSED", response.getStatus());
}
@Test
void staffCanCloseUnassignedConversation() {
Conversation conversation = conversation(99L, 1L, null, Conversation.ConversationStatus.OPEN);
when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation));
when(conversationRepository.save(any(Conversation.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(messageRepository.findByConversationIdOrderByTimestampAsc(99L)).thenReturn(List.of());
var response = chatService.updateConversation(99L, 77L, User.Role.STAFF, new UpdateConversationRequest("CLOSED"));
assertEquals("CLOSED", response.getStatus());
}
@Test
void adminCanCloseAnyConversation() {
Conversation conversation = conversation(99L, 2L, 88L, Conversation.ConversationStatus.OPEN);
when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation));
when(conversationRepository.save(any(Conversation.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(messageRepository.findByConversationIdOrderByTimestampAsc(99L)).thenReturn(List.of());
var response = chatService.updateConversation(99L, 1L, User.Role.ADMIN, new UpdateConversationRequest("CLOSED"));
assertEquals("CLOSED", response.getStatus());
}
@Test
void updateConversationCanReopenClosedConversation() {
Conversation conversation = conversation(99L, 1L, null, Conversation.ConversationStatus.CLOSED);
when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation));
when(customerRepository.findByUserId(10L)).thenReturn(Optional.of(customer));
when(conversationRepository.save(any(Conversation.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(messageRepository.findByConversationIdOrderByTimestampAsc(99L)).thenReturn(List.of());
var response = chatService.updateConversation(99L, 10L, User.Role.CUSTOMER, new UpdateConversationRequest("OPEN"));
assertEquals("OPEN", response.getStatus());
}
@Test
void updateConversationRejectsInvalidStatus() {
Conversation conversation = conversation(99L, 1L, null, Conversation.ConversationStatus.OPEN);
when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation));
when(customerRepository.findByUserId(10L)).thenReturn(Optional.of(customer));
assertThrows(IllegalArgumentException.class,
() -> chatService.updateConversation(99L, 10L, User.Role.CUSTOMER, new UpdateConversationRequest("INVALID")));
}
@Test
void sendMessageRejectsClosedConversation() {
Conversation conversation = conversation(99L, 1L, null, Conversation.ConversationStatus.CLOSED);
when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation));
assertThrows(AccessDeniedException.class,
() -> chatService.sendMessage(99L, 10L, User.Role.CUSTOMER, new MessageRequest("hello")));
verify(messageRepository, never()).save(any());
}
@Test
void requestHumanTakeoverRejectsClosedConversation() {
Conversation conversation = conversation(99L, 1L, null, Conversation.ConversationStatus.CLOSED);
when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation));
assertThrows(AccessDeniedException.class,
() -> chatService.requestHumanTakeover(99L, 10L, User.Role.CUSTOMER));
}
private Conversation conversation(Long id, Long customerId, Long staffId, Conversation.ConversationStatus status) {
Conversation conversation = new Conversation();
conversation.setId(id);
conversation.setCustomerId(customerId);
conversation.setStaffId(staffId);
conversation.setStatus(status);
conversation.setMode(Conversation.ConversationMode.AUTOMATED);
conversation.setHumanRequestedAt(LocalDateTime.now());
return conversation;
}
private Message message(String content) {
Message message = new Message();
message.setConversationId(99L);
message.setSenderId(10L);
message.setContent(content);
message.setIsRead(false);
return message;
}
}

View File

@@ -0,0 +1,180 @@
package com.petshop.backend.service;
import com.petshop.backend.dto.customer.CustomerRequest;
import com.petshop.backend.entity.Customer;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.CustomerRepository;
import com.petshop.backend.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CustomerServiceTest {
@Mock
private CustomerRepository customerRepository;
@Mock
private UserRepository userRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private UserBusinessLinkageService userBusinessLinkageService;
@InjectMocks
private CustomerService customerService;
@Test
void createCustomerCreatesLinkedUser() {
CustomerRequest request = new CustomerRequest();
request.setFirstName("Pat");
request.setLastName("Owner");
request.setEmail("pat@example.com");
Customer savedCustomer = new Customer();
savedCustomer.setCustomerId(7L);
savedCustomer.setFirstName("Pat");
savedCustomer.setLastName("Owner");
savedCustomer.setEmail("pat@example.com");
when(customerRepository.save(any(Customer.class))).thenReturn(savedCustomer);
when(userRepository.findByEmail("pat@example.com")).thenReturn(Optional.empty());
when(passwordEncoder.encode(any())).thenReturn("hashed-temp-password");
when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
User user = invocation.getArgument(0);
user.setId(11L);
return user;
});
when(userBusinessLinkageService.ensureLinkedCustomer(any(User.class))).thenAnswer(invocation -> {
User user = invocation.getArgument(0);
savedCustomer.setUserId(user.getId());
return savedCustomer;
});
var response = customerService.createCustomer(request);
assertNotNull(response);
assertEquals("Pat", response.getFirstName());
assertEquals("Owner", response.getLastName());
assertEquals("pat@example.com", response.getEmail());
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(userCaptor.capture());
User createdUser = userCaptor.getValue();
assertEquals("customer_7", createdUser.getUsername());
assertEquals("hashed-temp-password", createdUser.getPassword());
assertEquals("pat@example.com", createdUser.getEmail());
assertEquals("Pat Owner", createdUser.getFullName());
assertEquals("200-000-0007", createdUser.getPhone());
assertEquals(false, createdUser.getActive());
}
@Test
void createCustomerRejectsExistingNonCustomerEmail() {
CustomerRequest request = new CustomerRequest();
request.setFirstName("Pat");
request.setLastName("Owner");
request.setEmail("pat@example.com");
User existing = new User();
existing.setId(22L);
existing.setUsername("staff1");
existing.setEmail("pat@example.com");
existing.setRole(User.Role.STAFF);
when(userRepository.findByEmail("pat@example.com")).thenReturn(Optional.of(existing));
assertThrows(ResponseStatusException.class, () -> customerService.createCustomer(request));
}
@Test
void createCustomerRejectsExistingCustomerEmail() {
CustomerRequest request = new CustomerRequest();
request.setFirstName("Pat");
request.setLastName("Owner");
request.setEmail("pat@example.com");
User existing = new User();
existing.setId(22L);
existing.setUsername("customer1");
existing.setEmail("pat@example.com");
existing.setRole(User.Role.CUSTOMER);
when(userRepository.findByEmail("pat@example.com")).thenReturn(Optional.of(existing));
assertThrows(ResponseStatusException.class, () -> customerService.createCustomer(request));
}
@Test
void updateCustomerRejectsExistingEmailFromOtherUser() {
Customer customer = new Customer();
customer.setCustomerId(7L);
customer.setUserId(11L);
customer.setFirstName("Pat");
customer.setLastName("Owner");
customer.setEmail("old@example.com");
CustomerRequest request = new CustomerRequest();
request.setFirstName("Pat");
request.setLastName("Owner");
request.setEmail("pat@example.com");
User existing = new User();
existing.setId(22L);
existing.setUsername("customer2");
existing.setEmail("pat@example.com");
existing.setRole(User.Role.CUSTOMER);
when(customerRepository.findById(7L)).thenReturn(Optional.of(customer));
when(userRepository.findByEmail("pat@example.com")).thenReturn(Optional.of(existing));
assertThrows(ResponseStatusException.class, () -> customerService.updateCustomer(7L, request));
}
@Test
void deleteCustomerDeletesLinkedUser() {
Customer customer = new Customer();
customer.setCustomerId(7L);
customer.setUserId(11L);
when(customerRepository.findById(7L)).thenReturn(Optional.of(customer));
when(userRepository.existsById(11L)).thenReturn(true);
customerService.deleteCustomer(7L);
verify(userRepository).deleteById(11L);
verify(customerRepository, never()).deleteById(7L);
}
@Test
void deleteCustomerDeletesCustomerWhenNoLinkedUserExists() {
Customer customer = new Customer();
customer.setCustomerId(7L);
customer.setUserId(11L);
when(customerRepository.findById(7L)).thenReturn(Optional.of(customer));
when(userRepository.existsById(11L)).thenReturn(false);
customerService.deleteCustomer(7L);
verify(customerRepository).deleteById(7L);
}
}

View File

@@ -0,0 +1,17 @@
package com.petshop.backend.service;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SaleServiceTest {
@Test
void normalizePaymentMethodMapsDebitToCard() {
SaleService saleService = new SaleService(null, null, null, null, null, null, null, null);
assertEquals("Card", saleService.normalizePaymentMethod("Debit"));
assertEquals("Card", saleService.normalizePaymentMethod("debit"));
assertEquals("Cash", saleService.normalizePaymentMethod("Cash"));
}
}