diff --git a/.gitignore b/.gitignore index c2413da..9a1d717 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ firebase-service-account.json.b64 .idea/dataSources/dbb0ccd9-06dd-407f-b700-8142fac9bbbd.xml # IDE -.idea/ +.idea *.iws *.iml *.ipr @@ -25,7 +25,7 @@ build/ #ignore node modules node_modules/ #ignore build folder -build/ +build #ignore logs logs/ diff --git a/applicationsConfiguration b/applicationsConfiguration index 53a3b5f..47ede8f 160000 --- a/applicationsConfiguration +++ b/applicationsConfiguration @@ -1 +1 @@ -Subproject commit 53a3b5f70d2ad42777441d31bae55c66e4cd5d9b +Subproject commit 47ede8fd6674cb8b0b7a8fc746537e3c484c0bba diff --git a/common-utils/pom.xml b/common-utils/pom.xml index 06a1dc4..e36e623 100644 --- a/common-utils/pom.xml +++ b/common-utils/pom.xml @@ -154,6 +154,7 @@ + diff --git a/common-utils/src/main/java/com/tjtechy/client/InventoryServiceClient.java b/common-utils/src/main/java/com/tjtechy/client/InventoryServiceClient.java index 3f6d9f1..2c6766b 100644 --- a/common-utils/src/main/java/com/tjtechy/client/InventoryServiceClient.java +++ b/common-utils/src/main/java/com/tjtechy/client/InventoryServiceClient.java @@ -21,6 +21,7 @@ import reactor.core.publisher.Mono; import reactor.util.retry.Retry; + import java.time.Duration; import java.util.UUID; diff --git a/common-utils/target/classes/com/tjtechy/client/InventoryServiceClient.class b/common-utils/target/classes/com/tjtechy/client/InventoryServiceClient.class index a205af8..cb838b4 100644 Binary files a/common-utils/target/classes/com/tjtechy/client/InventoryServiceClient.class and b/common-utils/target/classes/com/tjtechy/client/InventoryServiceClient.class differ diff --git a/common-utils/target/common-utils-0.0.1.jar b/common-utils/target/common-utils-0.0.1.jar index fe00576..7fbf361 100644 Binary files a/common-utils/target/common-utils-0.0.1.jar and b/common-utils/target/common-utils-0.0.1.jar differ diff --git a/exception/src/main/java/com/tjtechy/modelNotFoundException/UserNotFoundException.java b/exception/src/main/java/com/tjtechy/modelNotFoundException/UserNotFoundException.java new file mode 100644 index 0000000..f165c8a --- /dev/null +++ b/exception/src/main/java/com/tjtechy/modelNotFoundException/UserNotFoundException.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2025 + * + * @Author = TJTechy (Tajudeen Busari) + * @Version = 1.0 + * This file is part of EcommerceMicroservices module of the Ecommerce Microservices project. + */ + +package com.tjtechy.modelNotFoundException; + +import java.util.List; +import java.util.UUID; + +public class UserNotFoundException extends RuntimeException{ + + public UserNotFoundException(UUID ID){ + super("User not found with id: " + ID); + } + + public UserNotFoundException(List IDS){ + super("Users not found with ids: " + IDS); + } +} diff --git a/exception/target/exception-0.0.1.jar b/exception/target/exception-0.0.1.jar index 722bd3b..0eb02e6 100644 Binary files a/exception/target/exception-0.0.1.jar and b/exception/target/exception-0.0.1.jar differ diff --git a/exception/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/exception/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst index 78f3679..f4eddb3 100644 --- a/exception/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst +++ b/exception/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -1,6 +1,7 @@ -com\tjtechy\businessException\InsufficientStockQuantityException.class com\tjtechy\businessException\OrderAlreadyCancelledException.class -com\tjtechy\modelNotFoundException\InventoryNotFoundException.class -com\tjtechy\modelNotFoundException\ProductNotFoundException.class com\tjtechy\modelNotFoundException\NotificationNotFoundException.class +com\tjtechy\modelNotFoundException\UserNotFoundException.class com\tjtechy\modelNotFoundException\OrderNotFoundException.class +com\tjtechy\businessException\InsufficientStockQuantityException.class +com\tjtechy\modelNotFoundException\InventoryNotFoundException.class +com\tjtechy\modelNotFoundException\ProductNotFoundException.class diff --git a/exception/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/exception/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst index 8f9de08..dd80937 100644 --- a/exception/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst +++ b/exception/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -4,3 +4,4 @@ C:\Users\tajud\javaProject\EcommerceMicroservices\exception\src\main\java\com\tj C:\Users\tajud\javaProject\EcommerceMicroservices\exception\src\main\java\com\tjtechy\modelNotFoundException\NotificationNotFoundException.java C:\Users\tajud\javaProject\EcommerceMicroservices\exception\src\main\java\com\tjtechy\modelNotFoundException\OrderNotFoundException.java C:\Users\tajud\javaProject\EcommerceMicroservices\exception\src\main\java\com\tjtechy\modelNotFoundException\ProductNotFoundException.java +C:\Users\tajud\javaProject\EcommerceMicroservices\exception\src\main\java\com\tjtechy\modelNotFoundException\UserNotFoundException.java diff --git a/notification-service/pom.xml b/notification-service/pom.xml index c117c47..04ef411 100644 --- a/notification-service/pom.xml +++ b/notification-service/pom.xml @@ -215,8 +215,13 @@ springdoc-openapi-starter-webmvc-api 2.8.9 + + org.testcontainers + junit-jupiter + test + - + diff --git a/order-service/pom.xml b/order-service/pom.xml index 8526b6e..0ab659c 100644 --- a/order-service/pom.xml +++ b/order-service/pom.xml @@ -40,6 +40,7 @@ spring-boot-starter-data-jpa + org.springframework.boot spring-boot-starter-web @@ -66,12 +67,6 @@ - - - - - - @@ -136,6 +131,10 @@ spring-cloud-starter-bootstrap + com.fasterxml.jackson.datatype jackson-datatype-hibernate6 @@ -201,7 +200,7 @@ - diff --git a/order-service/src/test/java/com/tjtechy/order_service/controller/OrderControllerTest.java b/order-service/src/test/java/com/tjtechy/order_service/controller/OrderControllerTest.java index 3b914bb..6f2863e 100644 --- a/order-service/src/test/java/com/tjtechy/order_service/controller/OrderControllerTest.java +++ b/order-service/src/test/java/com/tjtechy/order_service/controller/OrderControllerTest.java @@ -252,7 +252,6 @@ void createOrderSuccess() throws Exception { //Then: Verify Interactions verify(orderService).processOrderReactively(any(Order.class)); - } @Test diff --git a/order-service/src/test/java/com/tjtechy/order_service/service/impl/OrderServiceImplTest.java b/order-service/src/test/java/com/tjtechy/order_service/service/impl/OrderServiceImplTest.java index 40709b8..4c09632 100644 --- a/order-service/src/test/java/com/tjtechy/order_service/service/impl/OrderServiceImplTest.java +++ b/order-service/src/test/java/com/tjtechy/order_service/service/impl/OrderServiceImplTest.java @@ -74,10 +74,6 @@ class OrderServiceImplTest { @Mock private OrderEventProducer orderEventProducer; - - - - private static final Logger logger = LoggerFactory.getLogger(OrderServiceImplTest.class); diff --git a/pom.xml b/pom.xml index 7a19b64..69d798f 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ inventory-service notification-service user-service + security @@ -66,24 +67,6 @@ micrometer-observation - - - - - - - - - - - - - - - diff --git a/postgres-init/init-multiple-dbs.sql b/postgres-init/init-multiple-dbs.sql index 7ba6979..6b2243f 100644 --- a/postgres-init/init-multiple-dbs.sql +++ b/postgres-init/init-multiple-dbs.sql @@ -4,12 +4,14 @@ CREATE DATABASE "ECommerce-Order-Service"; CREATE DATABASE "ECommerce-Product-Service"; CREATE DATABASE "ECommerce-Inventory-Service"; CREATE DATABASE "ECommerce-Notification-Service"; +CREATE DATABASE "ECommerce-User-Service"; -- Grant all privileges on the databases to the postgres user GRANT ALL PRIVILEGES ON DATABASE "ECommerce-Order-Service" TO postgres; GRANT ALL PRIVILEGES ON DATABASE "ECommerce-Product-Service" TO postgres; GRANT ALL PRIVILEGES ON DATABASE "ECommerce-Inventory-Service" TO postgres; GRANT ALL PRIVILEGES ON DATABASE "ECommerce-Notification-Service" TO postgres; +GRANT ALL PRIVILEGES ON DATABASE "ECommerce-User-Service" TO postgres; -- print a message to indicate that the databases have been created -- sql dialect is not configured statement can be ignored or just click use PostgreSQL diff --git a/product-service/src/test/java/com/tjtechy/product_service/controller/ProductControllerTest.java b/product-service/src/test/java/com/tjtechy/product_service/controller/ProductControllerTest.java index 33842d2..d18d980 100644 --- a/product-service/src/test/java/com/tjtechy/product_service/controller/ProductControllerTest.java +++ b/product-service/src/test/java/com/tjtechy/product_service/controller/ProductControllerTest.java @@ -187,7 +187,7 @@ void testAddProductSuccess() throws Exception { new BigDecimal("100.0"), 100, "Category 1", - 100, //available stock, should now be same as product quantity + 100, //available stock should now be the same as product quantity LocalDate.now().plusDays(30),//expiry date LocalDate.of(2024, 10, 10),//manufactured date LocalDate.of(2026, 9, 5)));//updated date @@ -235,7 +235,7 @@ void testAddProductSuccessWithInventory() throws Exception { new BigDecimal("100.0"), 100, "Category 1", - 100, //available stock, should now be same as product quantity + 100, //available stock should now be the same as product quantity LocalDate.now().plusDays(30), LocalDate.of(2024, 10, 10),//manufactured date LocalDate.of(2026, 9, 5)));//updated date @@ -279,7 +279,7 @@ void testAddProductSuccessWithExternalizedService() throws Exception { new BigDecimal("100.0"), 100, "Category 1", - 100, //available stock, should now be same as product quantity + 100, //available stock should now be the same as product quantity LocalDate.now().plusDays(30),//expiry date LocalDate.of(2024, 10, 10),//manufactured date LocalDate.of(2026, 9, 5)));//updated date diff --git a/product-service/src/test/resources/application-test.yml b/product-service/src/test/resources/application-test.yml index 0fbea10..8847251 100644 --- a/product-service/src/test/resources/application-test.yml +++ b/product-service/src/test/resources/application-test.yml @@ -5,10 +5,8 @@ spring: username: postgres password: postgres hikari: - max-lifetime: 300000 connection-timeout: 15000 - validation-timeout: 3000 jpa: diff --git a/security/pom.xml b/security/pom.xml new file mode 100644 index 0000000..e92de9c --- /dev/null +++ b/security/pom.xml @@ -0,0 +1,36 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.2 + + + + com.tjtechy + security + 0.0.1 + jar + security + Security Module for ECommerce Microservices + + + 17 + 17 + + + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.security + spring-security-config + + + + \ No newline at end of file diff --git a/security/src/main/java/com/tjtechy/security/config/SecurityConfiguration.java b/security/src/main/java/com/tjtechy/security/config/SecurityConfiguration.java new file mode 100644 index 0000000..0ab0323 --- /dev/null +++ b/security/src/main/java/com/tjtechy/security/config/SecurityConfiguration.java @@ -0,0 +1,55 @@ +/** + * Copyright © 2025 + * + * @Author = TJTechy (Tajudeen Busari) + * @Version = 1.0 + * This file is part of EcommerceMicroservices module of the Ecommerce Microservices project. + */ + +package com.tjtechy.security.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import org.springframework.security.web.server.SecurityWebFilterChain; + +@Configuration +@EnableWebFluxSecurity +public class SecurityConfiguration { + /** + * Password encoder bean using BCrypt + * @return PasswordEncoder + * note: BCrypt is a strong hashing algorithm that is widely used for securely storing passwords. + * This bean is needed when using the PasswordEncoder interface in the application. + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * Security filter chain configuration + * Once there is a spring security dependency, by default, all endpoints are secured. + * So make a state change like a POST, PUT, DELETE request to any endpoint will require CSRF token. + * For simplicity, we are disabling CSRF here. + * @param http + * @return + * @throws Exception + */ + @Bean + public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) throws Exception { + return http + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .authorizeExchange(exchanges -> exchanges + .pathMatchers("/api/v1/user/register").permitAll() + .anyExchange() + .permitAll() + ) + .build(); + } +} diff --git a/system/pom.xml b/system/pom.xml index c5a9dc9..2eaa284 100644 --- a/system/pom.xml +++ b/system/pom.xml @@ -36,6 +36,14 @@ org.springframework.boot spring-boot-starter-actuator + + org.springframework.boot + spring-boot-actuator + + + org.springframework.boot + spring-boot-actuator + diff --git a/system/target/system-0.0.1.jar b/system/target/system-0.0.1.jar index 7e29f2b..382f13b 100644 Binary files a/system/target/system-0.0.1.jar and b/system/target/system-0.0.1.jar differ diff --git a/user-service/.gitignore b/user-service/.gitignore index 667aaef..676d636 100644 --- a/user-service/.gitignore +++ b/user-service/.gitignore @@ -31,3 +31,6 @@ build/ ### VS Code ### .vscode/ + +##ignore log files +*.log diff --git a/user-service/README.md b/user-service/README.md new file mode 100644 index 0000000..aeb96c8 --- /dev/null +++ b/user-service/README.md @@ -0,0 +1,44 @@ +The user-service module is responsible for managing user-related operations within the application. +It provides functionalities such as user registration, authentication, profile management, and password recovery. +It is based on Reactive webflux framework to handle asynchronous data streams efficiently, so the +dependencies are designed accordingly and are different from traditional Spring MVC based modules (like inventory-service etc.). +When the application starts, it does not automatically create DB TABLE because it does not use the spring.jpa.hibernate.ddl-auto property. +You need to create the necessary tables manually in the database before running the application or use a database migration tool like Flyway +or Liquibase to manage your database schema. +---------------------------------------------------------------------------------------------- +OPTION1: Manually create the necessary tables in the database before running the application. +OPTION2: Use a database migration tool like Flyway or Liquibase to manage your database schema + add migration scripts in, for example: + **src/main/resources/db/migration folder**. + V1__create_users_table.sql + Flyway runs on startup (blocking JDBC) and ensures that table exits, works with reactive R2DBC. + SAMPLE CONTENT: + -------------------------------------------------------------------------- + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(100) NOT NULL, + password VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() + ); + + -- Optional: trigger to update 'updated_at' on row update + CREATE OR REPLACE FUNCTION update_updated_at_column() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = now(); + RETURN NEW; + END; + $$ language 'plpgsql'; + + CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + ----------------------------------------------------------------------------------------------------------------- + + + +it is advisable not to make directly changes in the .sql scripts else it may lead to inconsistencies in the database schema management. +In this case, you will have to delete the database and re-create it again to ensure that the migration scripts are applied correctly from scratch. diff --git a/user-service/pom.xml b/user-service/pom.xml index 0ff7318..decf88f 100644 --- a/user-service/pom.xml +++ b/user-service/pom.xml @@ -9,7 +9,6 @@ ../pom.xml - user-service 0.0.1 user-service @@ -27,45 +26,219 @@ - - 17 - 2024.0.0 - + + 17 + 2024.0.0 + 17 + 17 + + + org.springframework.boot - spring-boot-starter-data-jpa + spring-boot-starter-webflux - + + - - + + + org.springframework.boot - spring-boot-devtools - runtime - true + spring-boot-starter-data-r2dbc + + + + org.postgresql + r2dbc-postgresql + + org.postgresql postgresql runtime + + + + org.springframework.cloud + spring-cloud-starter-config + + + org.springframework.boot - spring-boot-starter-test - test + spring-boot-devtools + runtime + true + + + + + org.springframework.cloud + spring-cloud-starter-bootstrap + + + + + com.fasterxml.jackson.datatype + jackson-datatype-hibernate6 + 2.18.2 + + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.18.2 + + + + + jakarta.validation + jakarta.validation-api + + + + + com.fasterxml.jackson.core + jackson-databind + 2.18.2 + + + + + org.springframework.boot + spring-boot-starter-cache + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.springframework.kafka + spring-kafka - + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + - - - + + + + + + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + + + com.tjtechy + common-utils + 0.0.1 + + + + com.tjtechy + exception + 0.0.1 + + + + com.tjtechy + security + 0.0.1 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-starter-tomcat + + + + + io.projectreactor + reactor-test + + + + org.springframework.kafka + spring-kafka-test + test + + + + org.testcontainers + kafka + test + + + + + org.testcontainers + postgresql + test + + + + + org.testcontainers + junit-jupiter + 1.20.4 + test + + + + org.testcontainers + r2dbc + test + 1.20.4 + + diff --git a/user-service/src/main/java/com/tjtechy/user_service/UserServiceApplication.java b/user-service/src/main/java/com/tjtechy/user_service/UserServiceApplication.java index bbd6c9d..13316f8 100644 --- a/user-service/src/main/java/com/tjtechy/user_service/UserServiceApplication.java +++ b/user-service/src/main/java/com/tjtechy/user_service/UserServiceApplication.java @@ -1,9 +1,19 @@ package com.tjtechy.user_service; +import com.tjtechy.RedisCacheConfig; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.connection.RedisConfiguration; -@SpringBootApplication +@SpringBootApplication(scanBasePackages = { + "com.tjtechy.security", "com.tjtechy.user_service" +}) +@EnableCaching +@EnableDiscoveryClient +@Import(RedisCacheConfig.class) public class UserServiceApplication { public static void main(String[] args) { diff --git a/user-service/src/main/java/com/tjtechy/user_service/controller/UserController.java b/user-service/src/main/java/com/tjtechy/user_service/controller/UserController.java new file mode 100644 index 0000000..0d26403 --- /dev/null +++ b/user-service/src/main/java/com/tjtechy/user_service/controller/UserController.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2025 + * + * @Author = TJTechy (Tajudeen Busari) + * @Version = 1.0 + * This file is part of EcommerceMicroservices module of the Ecommerce Microservices project. + */ + +package com.tjtechy.user_service.controller; + +import com.tjtechy.Result; +import com.tjtechy.StatusCode; +import com.tjtechy.user_service.entity.dto.UserRegistrationDto; +import com.tjtechy.user_service.mapper.UserMapper; +import com.tjtechy.user_service.service.UserService; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +@RestController +@RequestMapping("${api.endpoint.base-url}/user") +public class UserController { + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @PostMapping("/register") + public Mono addUser(@Valid @RequestBody UserRegistrationDto registrationDto) { + //map to user entity + var userEntity = UserMapper.mapFromUserRegistrationDtoToUser(registrationDto); + return userService.createUser(userEntity) + .map(savedUser -> { + //map to dto + var userDto = UserMapper.mapFromUserToUserDto(savedUser); + return new Result("User created successfully", true, userDto, StatusCode.SUCCESS); + }); + } + + @GetMapping("/by-username") + public Mono getUserByUsername(@RequestParam String username) { + return userService.findUserByUsername(username) + .map(user -> { + var userDto = UserMapper.mapFromUserToUserDto(user); + return new Result("User retrieved successfully", true, userDto, StatusCode.SUCCESS); + }); + } + + + @DeleteMapping("/{userId}") + public Mono deleteUser(@PathVariable UUID userId) { + return userService.deleteUser(userId) + .then(Mono.just(new Result("User deleted successfully", true, StatusCode.SUCCESS))); + } +} diff --git a/user-service/src/main/java/com/tjtechy/user_service/entity/User.java b/user-service/src/main/java/com/tjtechy/user_service/entity/User.java new file mode 100644 index 0000000..e1c0adc --- /dev/null +++ b/user-service/src/main/java/com/tjtechy/user_service/entity/User.java @@ -0,0 +1,228 @@ +/** + * Copyright © 2025 + * + * @Author = TJTechy (Tajudeen Busari) + * @Version = 1.0 + * This file is part of EcommerceMicroservices module of the Ecommerce Microservices project. + */ + +package com.tjtechy.user_service.entity; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.io.Serializable; +import java.time.LocalDate; +import java.util.UUID; + +/** + * User entity representing a user in the system. + * This class is mapped to the "users" table in the database. + * Because this module is built using Spring WebFlux with R2DBC as against the MVC with JPA/Hibernate, + * it uses reactive programming paradigms for non-blocking database interactions, and so + * we avoid using JPA annotations like @Entity (it is a JPA Hibernate annotation). + * No @Entity + * No @GeneratedValue + * No @PrePersist or @PreUpdate lifecycle callbacks + * No ddl-auto configurations + */ + +@Table(name = "users") +public class User implements Serializable { + @Id + private UUID userId = UUID.randomUUID(); + + @Column("user_name") + @NotBlank(message = "Username is required") + @Size(min = 4, max = 50, message = "Username must be between 4 and 50 characters") + private String userName; + + @NotBlank(message = "First name is required") + @Column("first_name") + private String firstName; + + @NotBlank(message = "Last name is required") + @Column("last_name") + private String lastName; + + @NotBlank(message = "Email is required") + @Email(message = "Email should be valid") + @Column("email") + private String email; + + @NotBlank(message = "Password is required") + @Size(min = 6, message = "Password must be at least 6 characters long") + @Column("password") + private String password; + + @Column("enabled") + private boolean enabled = true; + + @Column("role") + private Role role; //ENUM STORED AS STRING BY DEFAULT IN WEbFLUX/r2dbc + + @Column("phone_number") + @Size(min = 7, max = 15, message = "Phone number must be between 1 and 15 characters") + @Pattern( + regexp = "^(\\+\\d{1,3}[- ]?)?\\d{7,15}$", + message = "Invalid phone number format" + ) + private String phoneNumber; + + @Column("created_at") + private LocalDate createdAt = LocalDate.now(); + @Column("updated_at") + private LocalDate updatedAt = LocalDate.now(); + + public User updateTimestamps() { + + if (this.createdAt == null) { + this.createdAt = LocalDate.now(); + } + this.updatedAt = LocalDate.now(); + return this; + } + + /** + * Automatically sets the createdAt and updatedAt fields before persisting and updating the entity. + * Commonly used in jpa to initialize timestamp or audit fields. + */ + + public User() { + } + public User(UUID userId, + String userName, + String firstName, + String lastName, + String email, + String password, + boolean enabled, + Role role, + String phoneNumber, + LocalDate createdAt, + LocalDate updatedAt) { + this.userId = userId; + this.userName = userName; + this.firstName = firstName; + this.lastName = lastName; + this.email = email; + this.password = password; + this.enabled = enabled; + this.role = role; + this.phoneNumber = phoneNumber; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public UUID getUserId() { + return userId; + } + + public void setUserId(UUID userId) { + this.userId = userId; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getUserName() { + return userName; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Role getRole() { + return role; + } + + public void setRole(Role role) { + this.role = role; + } + public LocalDate getCreatedAt() { + return createdAt; + } + public void setCreatedAt(LocalDate createdAt) { + this.createdAt = createdAt; + } + public LocalDate getUpdatedAt() { + return updatedAt; + } + public void setUpdatedAt(LocalDate updatedAt) { + this.updatedAt = updatedAt; + } + + @Override + public String toString() { + return "User{" + + "userId=" + userId + + ", firstName='" + firstName + '\'' + + ", lastName='" + lastName + '\'' + + ", userName='" + userName + '\'' + + ", phoneNumber='" + phoneNumber + '\'' + + ", email='" + email + '\'' + +// ", password='" + password + '\'' + // Avoid logging password + ", enabled=" + enabled + + ", role=" + role + + ", createdAt=" + createdAt + + ", updatedAt=" + updatedAt + + '}'; + } + + public enum Role { + ADMIN, + CUSTOMER, + VENDOR + } +} diff --git a/user-service/src/main/java/com/tjtechy/user_service/entity/dto/UserDto.java b/user-service/src/main/java/com/tjtechy/user_service/entity/dto/UserDto.java new file mode 100644 index 0000000..e7f7d82 --- /dev/null +++ b/user-service/src/main/java/com/tjtechy/user_service/entity/dto/UserDto.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2025 + * + * @Author = TJTechy (Tajudeen Busari) + * @Version = 1.0 + * This file is part of EcommerceMicroservices module of the Ecommerce Microservices project. + */ +package com.tjtechy.user_service.entity.dto; + +import com.tjtechy.user_service.entity.User; + +import java.util.UUID; + +public record UserDto( + UUID userId, + String userName, + String email, + String firstName, + String lastName, + boolean enabled, + User.Role role +) { +} diff --git a/user-service/src/main/java/com/tjtechy/user_service/entity/dto/UserRegistrationDto.java b/user-service/src/main/java/com/tjtechy/user_service/entity/dto/UserRegistrationDto.java new file mode 100644 index 0000000..9ebd317 --- /dev/null +++ b/user-service/src/main/java/com/tjtechy/user_service/entity/dto/UserRegistrationDto.java @@ -0,0 +1,44 @@ +/** + * Copyright © 2025 + * + * @Author = TJTechy (Tajudeen Busari) + * @Version = 1.0 + * This file is part of EcommerceMicroservices module of the Ecommerce Microservices project. + */ + +package com.tjtechy.user_service.entity.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record UserRegistrationDto( + @NotBlank(message = "Username is required") + String userName, + + @NotBlank(message = "Email is required") + @Email(message = "Email should be valid") + String email, + + @NotBlank(message = "Password is required") + @Size(min = 6, message = "Password must be at least 6 characters long") + String password, + + @NotBlank(message = "First name is required") + String firstName, + + @NotBlank(message = "Last name is required") + String lastName, + + @Size(min = 7, max = 15, message = "Phone number must be between 1 and 15 characters") + @Pattern( + regexp = "^(\\+\\d{1,3}[- ]?)?\\d{7,15}$", + message = "Invalid phone number format" + ) + String phoneNumber, + + boolean enabled + +) { +} diff --git a/user-service/src/main/java/com/tjtechy/user_service/exception/ExceptionHandlingAdvice.java b/user-service/src/main/java/com/tjtechy/user_service/exception/ExceptionHandlingAdvice.java new file mode 100644 index 0000000..99939bb --- /dev/null +++ b/user-service/src/main/java/com/tjtechy/user_service/exception/ExceptionHandlingAdvice.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2025 + * + * @Author = TJTechy (Tajudeen Busari) + * @Version = 1.0 + * This file is part of EcommerceMicroservices module of the Ecommerce Microservices project. + */ + +package com.tjtechy.user_service.exception; + +import com.tjtechy.Result; +import com.tjtechy.StatusCode; +import com.tjtechy.modelNotFoundException.UserNotFoundException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class ExceptionHandlingAdvice { + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleIllegalArgumentException(IllegalArgumentException ex) { + return new Result(ex.getMessage(), false, StatusCode.BAD_REQUEST); + } + + @ExceptionHandler(UserNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public Result handleUserNotFoundException(Exception ex) { + return new Result(ex.getMessage(), false, StatusCode.NOT_FOUND); + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Result handleOtherExceptions(Exception ex) { + return new Result("A server internal error occurs", false, StatusCode.INTERNAL_SERVER_ERROR); + } +} diff --git a/user-service/src/main/java/com/tjtechy/user_service/mapper/UserMapper.java b/user-service/src/main/java/com/tjtechy/user_service/mapper/UserMapper.java new file mode 100644 index 0000000..2af0f98 --- /dev/null +++ b/user-service/src/main/java/com/tjtechy/user_service/mapper/UserMapper.java @@ -0,0 +1,64 @@ +/** + * Copyright © 2025 + * + * @Author = TJTechy (Tajudeen Busari) + * @Version = 1.0 + * This file is part of EcommerceMicroservices module of the Ecommerce Microservices project. + */ + +package com.tjtechy.user_service.mapper; + +import com.tjtechy.user_service.entity.User; +import com.tjtechy.user_service.entity.dto.UserDto; +import com.tjtechy.user_service.entity.dto.UserRegistrationDto; + + +import java.util.List; + +public class UserMapper { + /** + * Ensure ordering according to the UserDto constructor to avoid mapping errors + * or mismatches. + * @param user + * @return + */ + public static UserDto mapFromUserToUserDto(User user){ + return new UserDto( + user.getUserId(), + user.getUserName(), + user.getEmail(), + user.getFirstName(), + user.getLastName(), + user.isEnabled(), + user.getRole() + ); + } + + public static List mapFromUserListToUserDtoList(List users){ + return users.stream() + .map(UserMapper::mapFromUserToUserDto) + .toList(); + } + + /** + * Ensure ordering according to the User constructor to avoid mapping error + * or mismatches. + * @param registrationDto + * @return + */ + public static User mapFromUserRegistrationDtoToUser(UserRegistrationDto registrationDto){ + return new User( + null, + registrationDto.userName(), + registrationDto.firstName(), + registrationDto.lastName(), + registrationDto.email(), + registrationDto.password(), + registrationDto.enabled(), + null, + registrationDto.phoneNumber(), + null, + null + ); + } +} diff --git a/user-service/src/main/java/com/tjtechy/user_service/repository/UserRepository.java b/user-service/src/main/java/com/tjtechy/user_service/repository/UserRepository.java new file mode 100644 index 0000000..bc17d40 --- /dev/null +++ b/user-service/src/main/java/com/tjtechy/user_service/repository/UserRepository.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2025 + * + * @Author = TJTechy (Tajudeen Busari) + * @Version = 1.0 + * This file is part of EcommerceMicroservices module of the Ecommerce Microservices project. + */ + +package com.tjtechy.user_service.repository; + +import com.tjtechy.user_service.entity.User; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import reactor.core.publisher.Mono; + + +import java.util.UUID; + +/** + * This R2DBC repository internally extends ReactiveCrudRepository, which provides reactive CRUD operations + * for the User entity. It allows non-blocking database interactions using reactive programming paradigms. + */ +public interface UserRepository extends R2dbcRepository { + // Custom query method to find a user by username + Mono findByUserName(String userName); +} diff --git a/user-service/src/main/java/com/tjtechy/user_service/service/UserService.java b/user-service/src/main/java/com/tjtechy/user_service/service/UserService.java new file mode 100644 index 0000000..74abb53 --- /dev/null +++ b/user-service/src/main/java/com/tjtechy/user_service/service/UserService.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2025 + * + * @Author = TJTechy (Tajudeen Busari) + * @Version = 1.0 + * This file is part of EcommerceMicroservices module of the Ecommerce Microservices project. + */ +package com.tjtechy.user_service.service; + +import com.tjtechy.user_service.entity.User; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + + +import java.util.UUID; + +public interface UserService { + Mono createUser(User user); + Mono findUserByUsername(String username); + Mono findUserById(UUID id); + /** + * This is not ideal for large datasets as it loads all users into memory. + * It means wait till all users are fetched, then emit them as one big list. + * A better approach is to use Flux which streams users one by one. + * @return + */ + //Mono> getAllUsers(); + Flux findAllUsers(); + Mono updateUser(UUID id, User user); + Mono deleteUser(UUID id); + Mono deleteAllUsersByIds(Iterable ids); + Flux updateAllUsers(Flux users); +} diff --git a/user-service/src/main/java/com/tjtechy/user_service/service/impl/UserServiceImpl.java b/user-service/src/main/java/com/tjtechy/user_service/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..3405fb6 --- /dev/null +++ b/user-service/src/main/java/com/tjtechy/user_service/service/impl/UserServiceImpl.java @@ -0,0 +1,156 @@ +/** + * Copyright © 2025 + * + * @Author = TJTechy (Tajudeen Busari) + * @Version = 1.0 + * This file is part of EcommerceMicroservices module of the Ecommerce Microservices project. + */ + +package com.tjtechy.user_service.service.impl; + +import com.tjtechy.modelNotFoundException.UserNotFoundException; +import com.tjtechy.user_service.entity.User; +import com.tjtechy.user_service.repository.UserRepository; +import com.tjtechy.user_service.service.UserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +@Service +@Transactional +public class UserServiceImpl implements UserService { + private final UserRepository userRepository; + ///PasswordEncoder bean is configured in the security module, + /// without the bean, application will fail to start. + private final PasswordEncoder passwordEncoder; + private static final Logger logger = LoggerFactory.getLogger(UserServiceImpl.class); + + public UserServiceImpl(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + /** + * @param user + * @return + */ + @Override + public Mono createUser(User user) { + user.setPassword(passwordEncoder.encode(user.getPassword())); + //TODO: Assign role based on registration data in future. + // For example if admin is creating a user they should be able to set role to ADMIN. + //For now, all new users are assigned CUSTOMER role. + user.setRole(User.Role.CUSTOMER); + user.setEnabled(true); + return userRepository.save(user) + //TODO: Consider changing this to doOnNext if issues arise, so that step is only called when user is actually saved. + .doOnSuccess(savedUser -> { + logger.info("Created new user with username: {}", savedUser.getUserName()); + }) + .doOnError(error -> { + logger.error("Error creating user with username: {}: {}", user.getUserName(), error.getMessage()); + }); + } + + /** + * @param username + * @return + */ + @Override + @Cacheable(value = "user", key = "#username") + public Mono findUserByUsername(String username) { + return userRepository.findByUserName(username) + /** + * .doOnNext is only called when a user is found, else, it is skipped. + * if doOnSuccess is used, it will also log the Found with username even when user is not found, + * because Mono.empty() is considered a successful completion. + */ + .doOnNext(foundUser -> logger.info("Found with username: {}", username)) + .switchIfEmpty(Mono.defer(() -> { + logger.warn("User not found with username: {}", username); + return Mono.empty(); + })) + .doOnError(error -> + logger.error("Error finding user with username: {}: {}", username, error.getMessage())); + } + + /** + * @param id + * @return + */ + @Override + public Mono findUserById(UUID id) { + return null; + } + + /** + * @return + */ + @Override + public Flux findAllUsers() { + return null; + } + + /** + * @param id + * @param user + * @return + */ + @Override + public Mono updateUser(UUID id, User user) { + return null; + } + + /** + * @param id + * @return + */ + @Override + public Mono deleteUser(UUID id) { + //first find the user by id else throw error + return userRepository.findById(id) + .switchIfEmpty(Mono.error(new UserNotFoundException(id))) + //then delete the found user + .flatMap(user -> { + //if a user is enabled, first disable the user before deletion + if (user.isEnabled()){ + user.setEnabled(false); + return userRepository.save(user) + .doOnSuccess(u -> logger.info("Disabled user with id: {} before deletion", id)) + .then(userRepository.delete(user)) + .doOnSuccess(s -> logger.info("Deleted user with id : {}", id)) + .doOnError(e -> logger.error("Error deleting user with id: {}: {}", id, e.getMessage())); + } else { + //directly delete the user if already disabled + return userRepository.delete(user) + .doOnSuccess(su -> logger.info("Deleted user with id: {}", id)) + .doOnError(err -> logger.error("Error occurred while deleting user with id: {}: {}", id, err.getMessage())); + } + }); + } + + /** + * @param ids + * @return + */ + @Override + public Mono deleteAllUsersByIds(Iterable ids) { + return null; + } + + /** + * @param users + * @return + */ + @Override + public Flux updateAllUsers(Flux users) { + return null; + } +} diff --git a/user-service/src/main/resources/application.yml b/user-service/src/main/resources/application.yml index d363f9a..d6035be 100644 --- a/user-service/src/main/resources/application.yml +++ b/user-service/src/main/resources/application.yml @@ -3,7 +3,55 @@ server: spring: application: name: user-service # This name is used in WebClient URI - cloud: - config: - enable: false #Enable Spring Cloud Config Client - import-check: false #Enable import check to ensure config server is reachable +management: + endpoints: + web: + exposure: + include: health,info,metrics,env,beans,configprops,headdump,httpexchanges,loggers,mappings,customs-beans,prometheus + endpoint: + health: + show-details: always #show details in the health endpoint + env: + show-values: always #show values in the env endpoint + configprops: + show-values: always #show values in the configprops endpoint + info: + build: + enabled: true + env: + enabled: true + git: + enabled: true + java: + enabled: true + os: + enabled: true + tracing: + enabled: true + sampling: + probability: 1.0 # Adjust the sampling probability as needed (0.0 to 1.0) +tracing: + url: ${TRACING_URL:http://localhost:4318/v1/traces} #OTLP endpoint for Jaeger in local environment,defaults to localhost if not set.In docker-compose,it will be set to: http://ecommerce-jaeger:4318/v1/traces +# kafka configurations + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9093} + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + consumer: + group-id: user-service-group + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + properties: + spring.json.trusted.packages: '*' #trust all packages for JSON serialization/deserialization + metrics.reporters: "" + client.telemetry.reporter.enabled: false #disable client telemetry to reduce overhead + topics: + user-created: user-created-topic + user-updated: user-updated-topic + user-deleted: user-deleted-topic +logging: + level: + org.flywaydb: INFO #set Flyway logging level to INFO + org.springframework.r2dbc: INFO #set R2DBC logging level to DEBUG for detailed SQL logs + io.r2dbc: INFO #set R2DBC core logging level to DEBUG diff --git a/user-service/src/main/resources/boostrap.yml b/user-service/src/main/resources/boostrap.yml new file mode 100644 index 0000000..0f51098 --- /dev/null +++ b/user-service/src/main/resources/boostrap.yml @@ -0,0 +1,5 @@ +spring: + application: + name: user-service + config: + import: optional:configserver:http://localhost:8888 \ No newline at end of file diff --git a/user-service/src/main/resources/db/migration/V0__enable_pgcrypto.sql b/user-service/src/main/resources/db/migration/V0__enable_pgcrypto.sql new file mode 100644 index 0000000..470cc9a --- /dev/null +++ b/user-service/src/main/resources/db/migration/V0__enable_pgcrypto.sql @@ -0,0 +1,2 @@ +--------Enable pgcrypto extension for UUID generation-------- +CREATE EXTENSION IF NOT EXISTS pgcrypto; \ No newline at end of file diff --git a/user-service/src/main/resources/db/migration/V1__create_users_table.sql b/user-service/src/main/resources/db/migration/V1__create_users_table.sql new file mode 100644 index 0000000..dc0ea1c --- /dev/null +++ b/user-service/src/main/resources/db/migration/V1__create_users_table.sql @@ -0,0 +1,24 @@ +---log some info about the migration--- +DO $$ +BEGIN + RAISE NOTICE 'Starting migration: Creating users table'; +END $$; +CREATE TABLE IF NOT EXISTS users ( + + user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_name VARCHAR(50) NOT NULL UNIQUE, + first_name VARCHAR(50) NOT NULL, + last_name VARCHAR(50) NOT NULL, + email VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + enabled BOOLEAN DEFAULT TRUE, + role VARCHAR(50) NOT NULL, + phone_number VARCHAR(20), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); +---log some info about when complete--- +DO $$ +BEGIN + RAISE NOTICE 'Completed migration: Users table created'; +END $$; \ No newline at end of file diff --git a/user-service/src/main/resources/db/migration/V2__add_update_timestamp_trigger.sql b/user-service/src/main/resources/db/migration/V2__add_update_timestamp_trigger.sql new file mode 100644 index 0000000..e10f89b --- /dev/null +++ b/user-service/src/main/resources/db/migration/V2__add_update_timestamp_trigger.sql @@ -0,0 +1,24 @@ + +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + --only create the trigger if the users table exists--- +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'users') THEN + --use EXECUTE to avoid already exist errors--- + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_users_updated_at') + THEN + EXECUTE 'CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column();'; + END IF; + END IF; +END $$; + + diff --git a/user-service/src/test/java/com/tjtechy/user_service/UserServiceApplicationTests.java b/user-service/src/test/java/com/tjtechy/user_service/UserServiceApplicationTests.java index 22a8959..8c36645 100644 --- a/user-service/src/test/java/com/tjtechy/user_service/UserServiceApplicationTests.java +++ b/user-service/src/test/java/com/tjtechy/user_service/UserServiceApplicationTests.java @@ -1,32 +1,16 @@ package com.tjtechy.user_service; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; -import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -@SpringBootTest -//@ActiveProfiles("test") -/** - * This is disabled because the test profile is not set up with a database. - * It will be removed once the test profile is set up with a database. - * For now, to make CI passes, we disable this test. - */ -@EnableAutoConfiguration(exclude = { - DataSourceAutoConfiguration.class, - HibernateJpaAutoConfiguration.class -}) +@SpringBootTest class UserServiceApplicationTests { /** - * Just to check if the context loads + * Removed this test to avoid redundancy since and unncessary context load checks. + * The context load test is already present in UserControllerTest, which is more relevant. */ - @Test - void contextLoads() { - } +// @Test +// void contextLoads() { +// } } diff --git a/user-service/src/test/java/com/tjtechy/user_service/controller/TestSecurityConfig.java b/user-service/src/test/java/com/tjtechy/user_service/controller/TestSecurityConfig.java new file mode 100644 index 0000000..5436fc8 --- /dev/null +++ b/user-service/src/test/java/com/tjtechy/user_service/controller/TestSecurityConfig.java @@ -0,0 +1,34 @@ + +/** + * Copyright © 2025 + * @Author = TJTechy (Tajudeen Busari) + * @Version = 1.0 + * This file is part of EcommerceMicroservices module of the Ecommerce Microservices project. + */ + +package com.tjtechy.user_service.controller; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.SecurityWebFilterChain; + +/** + * Test security configuration that disables security for testing purposes. + * We have similar config in the security module, but in the future we may need to + * enable that in that module (security) and disable it here for testing only. + */ +@TestConfiguration +@EnableWebFluxSecurity +public class TestSecurityConfig { + @Bean + public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { + return http + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .authorizeExchange(exchanges -> exchanges + .anyExchange().permitAll() + ) + .build(); + } +} diff --git a/user-service/src/test/java/com/tjtechy/user_service/controller/UserControllerIntegrationTest.java b/user-service/src/test/java/com/tjtechy/user_service/controller/UserControllerIntegrationTest.java new file mode 100644 index 0000000..4ac0637 --- /dev/null +++ b/user-service/src/test/java/com/tjtechy/user_service/controller/UserControllerIntegrationTest.java @@ -0,0 +1,280 @@ +/** + * Copyright © 2025 + * + * @Author = TJTechy (Tajudeen Busari) + * @Version = 1.0 + * This file is part of EcommerceMicroservices module of the Ecommerce Microservices project. + */ + +package com.tjtechy.user_service.controller; + + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tjtechy.user_service.entity.dto.UserRegistrationDto; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.netflix.eureka.EurekaClientAutoConfiguration; +import org.springframework.cloud.netflix.eureka.EurekaDiscoveryClientConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; + +import java.util.Map; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, +properties = { + "spring.cloud.config.enabled=false", + "spring.cloud.config.discovery.enabled=false", + "spring.cloud.discovery.enabled=false", + "eureka.client.enabled=false", + "eureka.client.fetchRegistry=false", + "eureka.client.registerWithEureka=false", + "spring.cloud.loadbalancer.enabled=false", // Disable load balancer + "spring.cloud.service-registry.auto-registration.enabled=false", + "redis.enabled=false", //disable redis + "spring.cache.type=none", //disable caching +}) +@AutoConfigureWebTestClient +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // Use actual database configuration +@Tag("UserServiceIntegrationTest") +@ActiveProfiles("test") +@EnableAutoConfiguration(exclude = { + // Exclude auto-configurations that are not needed for integration tests + EurekaClientAutoConfiguration.class, + EurekaDiscoveryClientConfiguration.class, +}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@Import(TestSecurityConfig.class) //to disable security (csrf) for testing +public class UserControllerIntegrationTest { + @Autowired + private WebTestClient webTestClient; + + @Autowired + private ObjectMapper objectMapper; + + @Value("${api.endpoint.base-url}") + private String baseUrl; + + @Container + private static final PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer<>("postgres:latest") + .withDatabaseName("user_service_db") + .withUsername("testuser") + .withPassword("testpass"); + + @DynamicPropertySource + static void dynamicProperties(DynamicPropertyRegistry registry) { + registry.add("spring.r2dbc.url", () -> String.format("r2dbc:postgresql://%s:%d/%s", + postgreSQLContainer.getHost(), +// postgreSQLContainer.getFirstMappedPort(), + postgreSQLContainer.getMappedPort(PostgreSQLContainer.POSTGRESQL_PORT), + postgreSQLContainer.getDatabaseName() + )); + registry.add("spring.r2dbc.username", postgreSQLContainer::getUsername); + registry.add("spring.r2dbc.password", postgreSQLContainer::getPassword); + } + @BeforeAll + static void startContainers() { + postgreSQLContainer.start(); + } + + @AfterAll + static void stopContainers() { + if(postgreSQLContainer != null && postgreSQLContainer.isRunning()){ + postgreSQLContainer.stop(); + } + + } + + //private method to create user + private Map createUser() throws Exception { + var uniqueUsername = "BruceSmith" + System.currentTimeMillis(); + var userRegistrationDto = new UserRegistrationDto( + uniqueUsername, + "newuser" + System.currentTimeMillis() + "@email.com", + "BruceSmith@123", + "firstname1", + "lastname1", + "+1234567890", + true + ); + var requestBody = objectMapper.writeValueAsString(userRegistrationDto); + var responseBody = webTestClient.post() + .uri(baseUrl + "/user/register") + .bodyValue(requestBody) + .header("Content-Type", "application/json") + .exchange() + .expectStatus().isOk() + .expectBody() + .consumeWith(res -> { + System.out.println("ResponseBody: " + new String(res.getResponseBody())); + }) + .jsonPath("$.flag").isEqualTo(true) + .jsonPath("$.data.userId").isNotEmpty() + .jsonPath("$.data.userName").isEqualTo(uniqueUsername) + .jsonPath("$.data.enabled").isEqualTo(true) + .jsonPath("$.data.role").isEqualTo("CUSTOMER") + .returnResult() + .getResponseBody(); + + var result = objectMapper.readValue(responseBody, Map.class); + return (Map) result.get("data"); + } + + @Test + @DisplayName("Check Add User (POST /user/register)") + public void testCreateUserSuccess() throws Exception { + Map createdUser = createUser(); + System.out.println("Created User: " + createdUser); + //extract userId and username + String userId = (String) createdUser.get("userId"); + String userName = (String) createdUser.get("userName"); + System.out.println("Created User ID: " + userId); + System.out.println("Created Username: " + userName); + } + + @Test + @DisplayName("Check Add User Failure - Invalid Password Length (POST /user/register)") + public void testCreateUserWithInvalidPasswordLength() throws Exception { + UserRegistrationDto userRegistrationDto = new UserRegistrationDto( + "BruceSmith" + System.currentTimeMillis(), //to ensure unique username + "newuser@email.com", + "Bruce", // Invalid password length + "firstname1", + "lastname1", + "+1234567890", + false + ); + + var requestBody = objectMapper.writeValueAsString(userRegistrationDto); + webTestClient.post() + .uri(baseUrl + "/user/register") + .bodyValue(requestBody) + .header("Content-Type", "application/json") + .exchange() + .expectStatus().is5xxServerError() // Expecting server error due to invalid password, not client error + .expectBody() + .consumeWith(res -> { + System.out.println("ResponseBody: " + new String(res.getResponseBody())); + }) + .jsonPath("$.flag").isEqualTo(false) + .jsonPath("$.message").isEqualTo("A server internal error occurs"); + } + + @Test + @DisplayName("Check Add User Failure - Invalid Email (POST /user/register)") + public void testCreateUserWithInvalidEmail() throws Exception { + UserRegistrationDto userRegistrationDto = new UserRegistrationDto( + "BruceSmith" + System.currentTimeMillis(), //to ensure unique username + "newuser@.com", // Invalid email format + "BruceSmith@123", // Invalid password length + "firstname1", + "lastname1", + "+1234567890", + false + ); + + var requestBody = objectMapper.writeValueAsString(userRegistrationDto); + webTestClient.post() + .uri(baseUrl + "/user/register") + .bodyValue(requestBody) + .header("Content-Type", "application/json") + .exchange() + .expectStatus().is5xxServerError() // Expecting server error due to invalid password, not client error + .expectBody() + .consumeWith(res -> { + System.out.println("ResponseBody: " + new String(res.getResponseBody())); + }) + .jsonPath("$.flag").isEqualTo(false) + .jsonPath("$.message").isEqualTo("A server internal error occurs"); + } + + + /** + * Test adding a new user successfully. Same as testCreateUserSuccess but without using helper method. + * @throws Exception + */ + @Test + @DisplayName("Test Add User without helper method Success (POST /user/register)") + public void testAddUserSuccess() throws Exception { + UserRegistrationDto userRegistrationDto = new UserRegistrationDto( + "BruceSmith" + System.currentTimeMillis(), //to ensure unique username + //generate unique email as well + "newuser" + System.currentTimeMillis() + "@email.com", + "BruceSmith@123", + "firstname1", + "lastname1", + "+1234567890", + false + ); + + var requestBody = objectMapper.writeValueAsString(userRegistrationDto); + webTestClient.post() + .uri(baseUrl + "/user/register") + .bodyValue(requestBody) + .header("Content-Type", "application/json") + .exchange() + .expectStatus().isOk() + .expectBody() + .consumeWith(res -> { + System.out.println("ResponseBody: " + new String(res.getResponseBody())); + }) + .jsonPath("$.flag").isEqualTo(true) + .jsonPath("$.data.userId").isNotEmpty() + .jsonPath("$.data.userName").isEqualTo(userRegistrationDto.userName()) + .jsonPath("$.data.enabled").isEqualTo(true) + .jsonPath("$.data.role").isEqualTo("CUSTOMER"); + } + + @Test + @DisplayName("Check Get User By Username (GET /user/by-username?username=)") + public void testGetUserByUsernameSuccess() throws Exception { + //first create user + Map createdUser = createUser(); + String username = (String) createdUser.get("userName"); + + //then get user by username + webTestClient.get() + .uri(baseUrl + "/user/by-username" +"?username=" + username) + .header("Content-Type", "application/json") + .exchange() + .expectStatus().isOk() + .expectBody() + .consumeWith(res -> { + System.out.println("ResponseBody: " + new String(res.getResponseBody())); + }) + .jsonPath("$.flag").isEqualTo(true) + .jsonPath("$.data.userName").isEqualTo(username); + } + + @Test + @DisplayName("Check Delete User By ID (DELETE /user/{userId})") + public void testDeleteUserByIdSuccess() throws Exception { + //first create user + Map createdUser = createUser(); + String userId = (String) createdUser.get("userId"); + + //then delete user by userId + webTestClient.delete() + .uri(baseUrl + "/user/" + userId) + .header("Content-Type", "application/json") + .exchange() + .expectStatus().isOk() + .expectBody() + .consumeWith(res -> { + System.out.println("ResponseBody: " + new String(res.getResponseBody())); + }) + .jsonPath("$.flag").isEqualTo(true) + .jsonPath("$.code").isEqualTo(200) + .jsonPath("$.message").isEqualTo("User deleted successfully"); + } +} diff --git a/user-service/src/test/java/com/tjtechy/user_service/controller/UserControllerTest.java b/user-service/src/test/java/com/tjtechy/user_service/controller/UserControllerTest.java new file mode 100644 index 0000000..9e68883 --- /dev/null +++ b/user-service/src/test/java/com/tjtechy/user_service/controller/UserControllerTest.java @@ -0,0 +1,261 @@ +package com.tjtechy.user_service.controller; + + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tjtechy.RedisCacheConfig; +import com.tjtechy.user_service.entity.User; +import com.tjtechy.user_service.entity.dto.UserRegistrationDto; +import com.tjtechy.user_service.service.UserService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; +import org.springframework.cloud.netflix.eureka.EurekaClientAutoConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + + +@WebFluxTest(controllers = UserController.class) +@TestPropertySource(properties = { + "api.endpoint.base-url=/api/v1", + "spring.cache.type=none", // Disable caching for tests + "spring.redis.enabled=false", // Disable Redis for tests + "eureka.client.enabled=false",// Disable Eureka client for tests + "spring.cloud.config.enabled=false" // Disable Spring Cloud Config for tests +}) +@ImportAutoConfiguration(exclude = { + //exclude auto configurations that are not needed for the test + RedisCacheConfig.class, + EurekaClientAutoConfiguration.class, + +}) +@AutoConfigureWebTestClient +@Import(TestSecurityConfig.class) //to disable security (csrf) for testing +class UserControllerTest { + /** + * This is used instead of MockMvc for testing WebFlux controllers. + */ + @Autowired + private WebTestClient webTestClient; + + @Autowired + ObjectMapper objectMapper; + + @Value("${api.endpoint.base-url}") + private String baseUrl; + + @MockitoBean + private UserService userService; + + /** MockitoBean creates a mock of the RedisConnectionFactory + * to prevent actual Redis connections during testing. + */ + @MockitoBean + private RedisConnectionFactory redisConnectionFactory; + + private List users; + + + @BeforeEach + void setUp() { + users = new ArrayList<>(); + var user1 = new User(); + user1.setUserId(UUID.randomUUID()); + user1.setUserName("john_doe"); + user1.setPassword("password123"); + user1.setFirstName("John"); + user1.setLastName("Doe"); + user1.setEmail("john@doe.com"); + user1.setPhoneNumber("1234567890"); + user1.setRole(User.Role.CUSTOMER); + user1.setEnabled(true); + user1.setCreatedAt(LocalDate.now()); + user1.setUpdatedAt(LocalDate.now()); + users.add(user1); + + var user2 = new User(); + user2.setUserId(UUID.randomUUID()); + user2.setUserName("jane_smith"); + user2.setPassword("password456"); + user2.setFirstName("Jane"); + user2.setLastName("Smith"); + user2.setEmail("jane@smith.com"); + user2.setPhoneNumber("0987654321"); + user2.setRole(User.Role.CUSTOMER); + user2.setEnabled(true); + user2.setCreatedAt(LocalDate.now()); + user2.setUpdatedAt(LocalDate.now()); + users.add(user2); + + //USER WITH INVALID PASSWORD LENGTH + var user3 = new User(); + user3.setUserId(UUID.randomUUID()); + user3.setUserName("invalid_user"); + user3.setPassword("123"); // Invalid password length + user3.setFirstName("Invalid"); + user3.setLastName("User"); + user3.setEmail("invalid@invalid.com"); + user3.setPhoneNumber("1112223333"); + user3.setRole(User.Role.CUSTOMER); + user3.setEnabled(true); + user3.setCreatedAt(LocalDate.now()); + user3.setUpdatedAt(LocalDate.now()); + users.add(user3); + + //USER WITH INVALID EMAIL FORMAT + var user4 = new User(); + user4.setUserId(UUID.randomUUID()); + user4.setUserName("bad_email_user"); + user4.setPassword("validPassword1"); + user4.setFirstName("BadEmail"); + user4.setLastName("User"); + user4.setEmail("bademail.com"); // Invalid email format + user4.setPhoneNumber("4445556666"); + user4.setRole(User.Role.CUSTOMER); + user4.setEnabled(true); + user4.setCreatedAt(LocalDate.now()); + user4.setUpdatedAt(LocalDate.now()); + users.add(user4); + } + + @AfterEach + void tearDown() { + } + + @Test + @DisplayName("Add User Success POST api/v1/user/register") + void addUserSuccess() throws Exception { + //given + var registrationRequestDto = new UserRegistrationDto( + "john_doe", + "john@doe.com", + "securePassword", + "John", + "Doe", + "1234567890", + true + ); + var json = objectMapper.writeValueAsString(registrationRequestDto); + when(userService.createUser(any(User.class))).thenReturn(Mono.just(users.get(0))); + + //when and then + webTestClient.post() + + .uri(baseUrl + "/user/register") + .bodyValue(json) + .header("Content-Type", "application/json") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.flag").isEqualTo(true) + .jsonPath("$.message").isEqualTo("User created successfully") + .jsonPath("$.data.userName").isEqualTo("john_doe") + .jsonPath("$.data.email").isEqualTo("john@doe.com"); + } + + @Test + @DisplayName("Add User Failure - Invalid Password Length POST api/v1/user/register") + void addUserWithInvalidPassword() throws Exception { + //given + var registrationRequestDto = new UserRegistrationDto( + "invalid_user", + "invalid@invalid.com", + "123", // Invalid password length + "Invalid", + "User", + "1112223333", + true + ); + var json = objectMapper.writeValueAsString(registrationRequestDto); + when(userService.createUser(any(User.class))).thenThrow(new RuntimeException("Error creating user with username: invalid_user: Password must be at least 6 characters long")); + //when and then + webTestClient.post() + .uri(baseUrl + "/user/register") + .bodyValue(json) + .header("Content-Type", "application/json") + .exchange() + .expectStatus().is5xxServerError() // Expecting server error due to invalid password, not client error + .expectBody() + .jsonPath("$.flag").isEqualTo(false) + .jsonPath("$.message").isEqualTo("A server internal error occurs"); + } + + @Test + @DisplayName("Add User Failure - Invalid Email Format POST api/v1/user/register") + void addUserWithInvalidEmail() throws Exception { + //given + var registrationRequestDto = new UserRegistrationDto( + "bad_email_user", + "bademail.com", // Invalid email format + "validPassword1", + "BadEmail", + "User", + "4445556666", + true + ); + var json = objectMapper.writeValueAsString(registrationRequestDto); + when(userService.createUser(any(User.class))).thenThrow(new RuntimeException("Error creating user with username: bad_email_user: Email format is invalid")); + //when and then + webTestClient.post() + .uri(baseUrl + "/user/register") + .bodyValue(json) + .header("Content-Type", "application/json") + .exchange() + .expectStatus().is5xxServerError() // Expecting server error due to invalid email, not client error + .expectBody() + .jsonPath("$.flag").isEqualTo(false) + .jsonPath("$.message").isEqualTo("A server internal error occurs"); + } + + @Test + @DisplayName("Get User By Username Success GET api/v1/user/by-username") + void getUserByUsernameSuccess() { + //given + String username = "john_doe"; + when(userService.findUserByUsername(username)).thenReturn(Mono.just(users.get(0))); + //when and then + webTestClient.get() + .uri(baseUrl + "/user/by-username" + "?username=" + username) + .header("Content-Type", "application/json") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.flag").isEqualTo(true) + .jsonPath("$.message").isEqualTo("User retrieved successfully") + .jsonPath("$.data.userName").isEqualTo("john_doe"); + } + + @Test + @DisplayName("Delete User Success DELETE api/v1/user/{userId}") + void deleteUserSuccess() { + //given + UUID userId = users.get(0).getUserId(); + when(userService.deleteUser(userId)).thenReturn(Mono.empty()); + //when and then + webTestClient.delete() + .uri(baseUrl + "/user/" + userId) + .header("Content-Type", "application/json") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.flag").isEqualTo(true) + .jsonPath("$.message").isEqualTo("User deleted successfully"); + } +} \ No newline at end of file diff --git a/user-service/src/test/java/com/tjtechy/user_service/service/impl/UserServiceImplTest.java b/user-service/src/test/java/com/tjtechy/user_service/service/impl/UserServiceImplTest.java new file mode 100644 index 0000000..a214df4 --- /dev/null +++ b/user-service/src/test/java/com/tjtechy/user_service/service/impl/UserServiceImplTest.java @@ -0,0 +1,233 @@ +package com.tjtechy.user_service.service.impl; + +import com.tjtechy.modelNotFoundException.UserNotFoundException; +import com.tjtechy.user_service.entity.User; +import com.tjtechy.user_service.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +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.crypto.password.PasswordEncoder; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class UserServiceImplTest { + @Mock + private UserRepository userRepository; + @Mock + private PasswordEncoder passwordEncoder; + @InjectMocks + private UserServiceImpl userService; + + private List users; + + @BeforeEach + void setUp() { + users = new ArrayList<>(); + var user1 = new User(); + user1.setUserId(UUID.randomUUID()); + user1.setUserName("john_doe"); + user1.setPassword("password123"); + user1.setFirstName("John"); + user1.setLastName("Doe"); + user1.setEmail("john@doe.com"); + user1.setPhoneNumber("1234567890"); + user1.setRole(User.Role.CUSTOMER); + user1.setEnabled(true); + user1.setCreatedAt(LocalDate.now()); + user1.setUpdatedAt(LocalDate.now()); + users.add(user1); + + var user2 = new User(); + user2.setUserId(UUID.randomUUID()); + user2.setUserName("jane_smith"); + user2.setPassword("password456"); + user2.setFirstName("Jane"); + user2.setLastName("Smith"); + user2.setEmail("jane@smith.com"); + user2.setPhoneNumber("0987654321"); + user2.setRole(User.Role.CUSTOMER); + user2.setEnabled(true); + user2.setCreatedAt(LocalDate.now()); + user2.setUpdatedAt(LocalDate.now()); + users.add(user2); + + //USER WITH INVALID PASSWORD LENGTH + var user3 = new User(); + user3.setUserId(UUID.randomUUID()); + user3.setUserName("invalid_user"); + user3.setPassword("123"); // Invalid password length + user3.setFirstName("Invalid"); + user3.setLastName("User"); + user3.setEmail("invalid@invalid.com"); + user3.setPhoneNumber("1112223333"); + user3.setRole(User.Role.CUSTOMER); + user3.setEnabled(true); + user3.setCreatedAt(LocalDate.now()); + user3.setUpdatedAt(LocalDate.now()); + users.add(user3); + + //USER WITH INVALID EMAIL FORMAT + var user4 = new User(); + user4.setUserId(UUID.randomUUID()); + user4.setUserName("bad_email_user"); + user4.setPassword("validPassword1"); + user4.setFirstName("BadEmail"); + user4.setLastName("User"); + user4.setEmail("bademail.com"); // Invalid email format + user4.setPhoneNumber("4445556666"); + user4.setRole(User.Role.CUSTOMER); + user4.setEnabled(true); + user4.setCreatedAt(LocalDate.now()); + user4.setUpdatedAt(LocalDate.now()); + users.add(user4); + } + + @AfterEach + void tearDown() { + } + + @Test + void createUserSuccess() { + //Given + given(passwordEncoder.encode(users.get(0).getPassword())).willReturn("encodedPassword123"); + given(userRepository.save(users.get(0))).willReturn(Mono.just(users.get(0))); + + //When + var createdUserMono = userService.createUser(users.get(0)); + + //Then + /** + * StepVerifier is a utility from Project Reactor used for testing reactive streams. + * It allows you to create a test scenario where you can define expectations about the sequence of + * events (like emitted items, completion signals, or errors) that a Mono or Flux should produce. + * expectNext(users.get(0)) checks that the next item emitted by the Mono is equal to users.get(0). + * verifyComplete() verifies that the Mono completes successfully after emitting the expected item. + */ + StepVerifier.create(createdUserMono) + .expectNext(users.get(0)) + .verifyComplete(); + } + + @Test + void createUserWithInvalidPasswordLength() { + //Given + given(passwordEncoder.encode(users.get(2).getPassword())).willReturn("encodedPassword123"); + given(userRepository.save(users.get(2))).willReturn(Mono.error(new IllegalArgumentException("Password must be at least 6 characters long"))); + + //When + var createdUserMono = userService.createUser(users.get(2)); + //Then + StepVerifier.create(createdUserMono) + .expectErrorSatisfies(throwable -> { + assertTrue(throwable instanceof IllegalArgumentException); + assertEquals("Password must be at least 6 characters long", throwable.getMessage()); + }); + + } + + @Test + void createUserWithInvalidEmail() { + //Given + given(passwordEncoder.encode(users.get(3).getPassword())).willReturn("encodedPassword123"); + given(userRepository.save(users.get(3))).willReturn(Mono.error(new IllegalArgumentException("Email should be valid"))); + + //When + var createdUserMono = userService.createUser(users.get(3)); + //Then + StepVerifier.create(createdUserMono) + .expectErrorSatisfies(throwable -> { + assertTrue(throwable instanceof IllegalArgumentException); + assertEquals("Email should be valid", throwable.getMessage()); + }); + } + + @Test + void findUserByUsernameSuccess() { + //Given + given(userRepository.findByUserName(users.get(0).getUserName())).willReturn(Mono.just(users.get(0))); + + //When + var foundUserMono = userService.findUserByUsername(users.get(0).getUserName()); + + //Then + StepVerifier.create(foundUserMono) + .expectNext(users.get(0)) + .verifyComplete(); + } + + @Test + void findUserByUsernameNotFound() { + //Given + given(userRepository.findByUserName("non_existent_user")).willReturn(Mono.empty()); + + //When + var foundUserMono = userService.findUserByUsername("non_existent_user"); + + //Then + StepVerifier.create(foundUserMono) + .verifyComplete(); // Expecting completion without any emitted item + } + + + + @Test + void findUserById() { + } + + @Test + void findAllUsers() { + } + + @Test + void updateUser() { + } + + @Test + void deleteUserSuccess() { + //Given + given(userRepository.findById(users.get(0).getUserId())).willReturn(Mono.just(users.get(0))); + given(userRepository.save(users.get(0))).willReturn(Mono.just(users.get(0))); + given(userRepository.delete(users.get(0))).willReturn(Mono.empty()); + //When + var deleteUserMono = userService.deleteUser(users.get(0).getUserId()); + //Then + StepVerifier.create(deleteUserMono) + .verifyComplete(); + } + + @Test + void deleteUserNotFound() { + //Given + UUID nonExistentUserId = UUID.randomUUID(); + given(userRepository.findById(nonExistentUserId)).willReturn(Mono.empty()); + + //When + var deleteUserMono = userService.deleteUser(nonExistentUserId); + + //Then + StepVerifier.create(deleteUserMono) + .expectError(UserNotFoundException.class) + .verify(); + } + + @Test + void deleteAllUsersByIds() { + } + + @Test + void updateAllUsers() { + } +} \ No newline at end of file diff --git a/user-service/src/test/resources/application-test.yml b/user-service/src/test/resources/application-test.yml index 76d6331..04c2d4a 100644 --- a/user-service/src/test/resources/application-test.yml +++ b/user-service/src/test/resources/application-test.yml @@ -1,14 +1,33 @@ -#spring: -# datasource: -# url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE -# driver-class-name: org.h2.Driver -# username: sa -# password: "" -# jpa: -# database-platform: org.hibernate.dialect.H2Dialect -# hibernate: -# ddl-auto: create-drop -# show-sql: true -# sql: -# init: -# mode: always \ No newline at end of file +spring: + r2dbc: + url: r2dbc:tc:postgresql:15.0://user_service_db + username: postgres + password: postgres + pool: + initial-size: 5 + max-size: 20 + max-idle-time: 30000 #30 seconds + max-acquire-time: 60000 #60 seconds + sql: + init: + mode: always + schema-locations: classpath:schema.sql + flyway: + enabled: true #disable flyway during tests, we are not testing migrations here + cloud: + config: + enabled: false + discovery: + enabled: false + cache: + type: none + eureka: + client: + enabled: false + instance: + hostname: localhost + redis: + enabled: false +api: + endpoint: + base-url: /api/v1 \ No newline at end of file diff --git a/user-service/src/test/resources/schema.sql b/user-service/src/test/resources/schema.sql new file mode 100644 index 0000000..d387efb --- /dev/null +++ b/user-service/src/test/resources/schema.sql @@ -0,0 +1,15 @@ + +CREATE TABLE users ( + + user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_name VARCHAR(50) NOT NULL UNIQUE, + first_name VARCHAR(50) NOT NULL, + last_name VARCHAR(50) NOT NULL, + email VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + enabled BOOLEAN DEFAULT TRUE, + role VARCHAR(50) NOT NULL, + phone_number VARCHAR(20), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +);