diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..64df7ef Binary files /dev/null and b/.DS_Store differ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..50ce2f6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM eclipse-temurin:25-jdk AS build +WORKDIR /app +COPY mvnw . +COPY .mvn .mvn +COPY pom.xml . +COPY src src +RUN chmod +x ./mvnw +RUN ./mvnw clean package -DskipTests + +FROM eclipse-temurin:25-jre +WORKDIR /app +COPY --from=build /app/target/HyprLink-0.0.1-SNAPSHOT.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f27cdde --- /dev/null +++ b/README.md @@ -0,0 +1,189 @@ +# HyperLink (Spring Unit Project) + +HyperLink is a Spring Boot + Thymeleaf web app for creating a personal "link in bio" profile page. +Users can register, sign in, edit their profile, and share a public profile page. + +## Current project status + +- Core authentication flow is implemented (`/login`, `/register`, secured routes). +- Dashboard profile editing is implemented, including social links and style options. +- File uploads are supported for profile and background images from the dashboard. +- Public profile rendering is implemented at `/profile/{id}`. +- Seed data is loaded at startup when the user table is empty. +- Automated tests are in place and currently passing (`48/48` in latest local run). + +## What it does + +- User registration and login with Spring Security. +- Password hashing with BCrypt. +- Dashboard editing for: + - display name + - age / pronouns / bio + - profile image URL or uploaded profile image + - uploaded/custom background image + - theme + link/button/text style options + - social links +- Public profile page by user ID (`/profile/{id}`). + +## Tech stack + +- Java 25 +- Spring Boot 4.0.3 +- Spring MVC + Thymeleaf +- Spring Data JPA (Hibernate) +- Spring Security +- PostgreSQL (runtime configuration) +- H2 in-memory database for tests +- Docker (multi-stage image) +- Maven Wrapper (`./mvnw`) + +## Project structure + +```text +src/main/java/com/basecamp/HyprLink + config/ + DataLoader.java + controller/ + AuthController.java + DashboardController.java + ProfileController.java + entity/ + User.java + SocialLink.java + repository/ + UserRepository.java + security/ + SecurityConfig.java + CustomUserDetailService.java + service/ + AuthService.java + DashboardService.java + ProfileService.java + +src/main/resources + templates/ + index.html + dashboard.html + profile.html + auth/login.html + auth/register.html + static/css/ + auth.css + dashboard.css + default.css + landing.css + login.css + register.css +``` + +## Prerequisites + +- JDK 25 +- Maven (or use the included Maven Wrapper) +- PostgreSQL database for local app runtime +- (Optional) Docker + +## Configuration + +Main config: `src/main/resources/application.properties` + +```properties +spring.datasource.url=${DB_URL} +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} +spring.datasource.driver-class-name=org.postgresql.Driver +spring.jpa.hibernate.ddl-auto=create +server.port=${PORT:8080} +``` + +Set local runtime variables: + +```bash +export DB_URL="jdbc:postgresql://localhost:5432/your_database" +export DB_USERNAME="postgres" +export DB_PASSWORD="your_password" +``` + +## Run locally + +```bash +./mvnw spring-boot:run +``` + +Then open: + +- `http://localhost:8080/` +- `http://localhost:8080/login` +- `http://localhost:8080/register` + +## Run with Docker + +```bash +docker build -t hyperlink-app . +docker run -p 8080:8080 \ + -e DB_URL="jdbc:postgresql://:5432/" \ + -e DB_USERNAME="" \ + -e DB_PASSWORD="" \ + hyperlink-app +``` + +## Main routes + +- `GET /` - landing/welcome page (template `index.html`) +- `GET /login` - login form +- `GET /register` - registration form +- `POST /register` - account creation +- `GET /dashboard` - authenticated profile editor +- `POST /dashboard/save` - save dashboard profile changes +- `GET /profile/{id}` - public profile page +- `GET /images/background-templates/{filename}` - serve background template images + +## Seeded demo users + +`DataLoader` creates these users on first startup when the database is empty: + +- `johndoe` / `password123` +- `janesmith` / `password123` + +## Testing + +Run all tests: + +```bash +./mvnw test +``` + +### Latest verified test result + +- Date: `2026-03-18` +- Command: `./mvnw test` +- Result: `BUILD SUCCESS` +- Totals: `48 tests, 0 failures, 0 errors, 0 skipped` + +### Test suites and cases + +| Test class | Layer | Cases | +| --- | --- | ---: | +| `UserRepositoryTest` | Repository integration (`@SpringBootTest`, H2) | 17 | +| `SecurityConfigTest` | Security config unit | 4 | +| `CustomUserDetailServiceTest` | Security service unit | 2 | +| `ProfileControllerTest` | Controller unit | 3 | +| `DashboardControllerTest` | Controller unit | 3 | +| `AuthControllerTest` | Controller unit | 3 | +| `DashboardServiceTest` | Service unit | 7 | +| `AuthServiceTest` | Service unit | 5 | +| `ProfileServiceTest` | Service unit | 3 | +| `SpringUnitProjectApplicationTests` | Context load smoke test | 1 | + +### Notes on test configuration + +- Test properties are in `src/test/resources/application.properties`. +- Tests use H2 (`jdbc:h2:mem:testdb`) instead of PostgreSQL. +- Repository tests run with Spring context and transactions. +- Most controller/service/security tests use JUnit 5 + Mockito. + +## Notes + +- Security allows public access to `/`, `/login`, `/register`, `/profile/**`, and `/css/**`. +- Other routes require authentication. +- The app currently sets `spring.jpa.hibernate.ddl-auto=create` in main runtime config. diff --git a/pom.xml b/pom.xml index 8d9ea1b..0864365 100644 --- a/pom.xml +++ b/pom.xml @@ -92,6 +92,10 @@ h2 test + + org.springframework.boot + spring-boot-starter-validation + org.springframework.boot spring-boot-starter-security diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..ab58b38 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/main/.DS_Store b/src/main/.DS_Store new file mode 100644 index 0000000..5498eb8 Binary files /dev/null and b/src/main/.DS_Store differ diff --git a/src/main/java/.DS_Store b/src/main/java/.DS_Store new file mode 100644 index 0000000..d5125c2 Binary files /dev/null and b/src/main/java/.DS_Store differ diff --git a/src/main/java/com/.DS_Store b/src/main/java/com/.DS_Store new file mode 100644 index 0000000..5ba2a81 Binary files /dev/null and b/src/main/java/com/.DS_Store differ diff --git a/src/main/java/com/basecamp/.DS_Store b/src/main/java/com/basecamp/.DS_Store new file mode 100644 index 0000000..f96a26b Binary files /dev/null and b/src/main/java/com/basecamp/.DS_Store differ diff --git a/src/main/java/com/basecamp/HyprLink/controller/AuthController.java b/src/main/java/com/basecamp/HyprLink/controller/AuthController.java index 89d7587..90b63ab 100644 --- a/src/main/java/com/basecamp/HyprLink/controller/AuthController.java +++ b/src/main/java/com/basecamp/HyprLink/controller/AuthController.java @@ -1,27 +1,21 @@ package com.basecamp.HyprLink.controller; -import com.basecamp.HyprLink.entity.SocialLink; import com.basecamp.HyprLink.entity.User; -import com.basecamp.HyprLink.repository.UserRepository; -import org.springframework.security.crypto.password.PasswordEncoder; +import com.basecamp.HyprLink.service.AuthService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; -import java.util.ArrayList; import java.util.List; - @Controller public class AuthController { - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; + private final AuthService authService; - public AuthController(UserRepository userRepository, PasswordEncoder passwordEncoder) { - this.userRepository = userRepository; - this.passwordEncoder = passwordEncoder; + public AuthController(AuthService authService) { + this.authService = authService; } @GetMapping("/login") @@ -31,19 +25,42 @@ public String showLoginForm() { @GetMapping("/register") public String showRegistrationForm(Model model) { - User user = new User(); - List initialLinks = new ArrayList<>(); - initialLinks.add(new SocialLink()); - user.setSocialLinks(initialLinks); - model.addAttribute("user", new User()); + model.addAttribute("user", authService.prepareRegistrationFormData()); + model.addAttribute("themes", authService.getAvailableThemes()); + return "auth/register"; + } + + @PostMapping("/register/check") + public String checkRegistrationForm(@ModelAttribute User user, Model model) { + boolean validUser = true; model.addAttribute("themes", List.of("default", "dark")); + if (!authService.checkUsername(user.getUsername())) { + model.addAttribute("invalidUsername", true); + validUser = false; + System.out.print("Invalid username"); + } + if (authService.checkUserDoesNotExist(user.getUsername())) { + model.addAttribute("userAlreadyExists", true); + if (validUser) { + validUser = false; + } + System.out.print("Username exists alredy"); + } + if (!authService.checkPassword(user.getPassword())) { + model.addAttribute("invalidPassword", true); + if (validUser) { + validUser = false; + } + } + if (validUser) { + return registerUser(user); + } return "auth/register"; } @PostMapping("/register") public String registerUser(@ModelAttribute User user) { - user.setPassword(passwordEncoder.encode(user.getPassword())); - userRepository.save(user); - return "redirect:/login?success"; // Redirect to login page after signing up + authService.registerUser(user); + return "redirect:/login?success"; } } \ No newline at end of file diff --git a/src/main/java/com/basecamp/HyprLink/controller/DashboardController.java b/src/main/java/com/basecamp/HyprLink/controller/DashboardController.java index 5f50cd7..9ee60cc 100644 --- a/src/main/java/com/basecamp/HyprLink/controller/DashboardController.java +++ b/src/main/java/com/basecamp/HyprLink/controller/DashboardController.java @@ -3,18 +3,41 @@ import com.basecamp.HyprLink.entity.SocialLink; import com.basecamp.HyprLink.entity.User; import com.basecamp.HyprLink.repository.UserRepository; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ExceptionHandler; import java.security.Principal; import java.util.List; +import java.io.File; +import java.net.MalformedURLException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.Set; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import java.io.IOException; + @Controller public class DashboardController { + private static final String STATIC_BG_DIR = "src/main/resources/static/images/background-templates"; + private static final String PROJECT_BG_DIR = "src/main/resources/background templates"; + private static final String STATIC_UPLOAD_DIR = "src/main/resources/static/images/uploads"; + private final UserRepository userRepository; public DashboardController(UserRepository userRepository) { @@ -31,31 +54,53 @@ public String showDashboard(Principal principal, Model model) { user.getSocialLinks().add(new SocialLink()); } - model.addAttribute("user", user); - model.addAttribute("themes", List.of("default", "dark")); + + addDashboardModelData(model, user); return "dashboard"; } + @PostMapping("/dashboard/save") - public String saveProfile(@ModelAttribute User updatedData, Principal principal) { + public String saveProfile(@ModelAttribute("user") User updatedData, Principal principal, + @RequestParam(value = "profilePictureFile", required = false) MultipartFile profilePictureFile, + @RequestParam(value = "backgroundFile", required = false) MultipartFile backgroundFile) throws IOException { + User existingUser = userRepository.findByUsername(principal.getName()).orElseThrow(); + if (profilePictureFile != null && !profilePictureFile.isEmpty()) { + String filename = saveUploadFile(profilePictureFile, principal.getName() + "_"); + existingUser.setProfilePicture("/images/uploads/" + filename); + } else if (updatedData.getProfilePicture() != null && !updatedData.getProfilePicture().isEmpty()) { + existingUser.setProfilePicture(updatedData.getProfilePicture()); + } + + if (backgroundFile != null && !backgroundFile.isEmpty()) { + String filename = saveUploadFile(backgroundFile, principal.getName() + "_bg_"); + existingUser.setBackgroundImage("/images/uploads/" + filename); + } else if (updatedData.getBackgroundImage() != null && !updatedData.getBackgroundImage().isEmpty()) { + existingUser.setBackgroundImage(updatedData.getBackgroundImage()); + } // Update basic fields existingUser.setName(updatedData.getName()); existingUser.setAge(updatedData.getAge()); existingUser.setPronouns(updatedData.getPronouns()); existingUser.setBio(updatedData.getBio()); - existingUser.setProfilePicture(updatedData.getProfilePicture()); existingUser.setTheme(updatedData.getTheme()); - - // Update Social Links: Filter out any completely blank ones + existingUser.setLinkStyle(updatedData.getLinkStyle()); + existingUser.setTextAlign(updatedData.getTextAlign()); + existingUser.setButtonColor(updatedData.getButtonColor()); + existingUser.setFontFamily(updatedData.getFontFamily()); + System.out.println("Hello World"); + // Keep only links that have both a title and a URL if (updatedData.getSocialLinks() != null) { - List validLinks = updatedData.getSocialLinks().stream() - .filter(link -> link.getTitle() != null && !link.getTitle().isBlank() - && link.getUrl() != null && !link.getUrl().isBlank()) - .toList(); + List validLinks = new ArrayList<>(); + for (SocialLink link : updatedData.getSocialLinks()) { + if (link.getTitle() != null && !link.getTitle().isBlank() + && link.getUrl() != null && !link.getUrl().isBlank()) { + validLinks.add(link); + } + } - // Clear the old links and add the newly edited/filtered ones existingUser.getSocialLinks().clear(); existingUser.getSocialLinks().addAll(validLinks); } @@ -63,4 +108,123 @@ public String saveProfile(@ModelAttribute User updatedData, Principal principal) userRepository.save(existingUser); return "redirect:/dashboard?success"; } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public String handleUploadTooLarge() { + return "redirect:/dashboard?uploadTooLarge"; + } + + @GetMapping("/images/background-templates/{filename:.+}") + public ResponseEntity backgroundTemplate(@PathVariable String filename) throws MalformedURLException { + String safeFilename = Paths.get(filename).getFileName().toString(); + Path path = resolveBackgroundPath(safeFilename); + if (path == null) { + return ResponseEntity.notFound().build(); + } + + Resource resource = new UrlResource(path.toUri()); + MediaType mediaType = MediaTypeFactory.getMediaType(resource) + .orElse(MediaType.APPLICATION_OCTET_STREAM); + + return ResponseEntity.ok() + .contentType(mediaType) + .body(resource); + } + + @GetMapping("/images/uploads/{filename:.+}") + public ResponseEntity uploadedImage(@PathVariable String filename) throws MalformedURLException { + String safeFilename = Paths.get(filename).getFileName().toString(); + Path path = Paths.get(STATIC_UPLOAD_DIR, safeFilename); + if (!path.toFile().isFile()) { + return ResponseEntity.notFound().build(); + } + + Resource resource = new UrlResource(path.toUri()); + MediaType mediaType = MediaTypeFactory.getMediaType(resource) + .orElse(MediaType.APPLICATION_OCTET_STREAM); + + return ResponseEntity.ok() + .contentType(mediaType) + .body(resource); + } + + private List loadBackgrounds() { + Set backgrounds = new LinkedHashSet<>(); + addBackgroundsFromDirectory(backgrounds, PROJECT_BG_DIR); + addBackgroundsFromDirectory(backgrounds, STATIC_BG_DIR); + List sorted = new ArrayList<>(backgrounds); + sorted.sort((a, b) -> { + try { + int numA = Integer.parseInt(a.replaceAll("\\.[^.]+$", "")); + int numB = Integer.parseInt(b.replaceAll("\\.[^.]+$", "")); + return Integer.compare(numA, numB); + } catch (NumberFormatException e) { + return a.compareTo(b); + } + }); + return sorted; + } + + private void addBackgroundsFromDirectory(Set backgrounds, String directoryPath) { + File folder = new File(directoryPath); + if (!folder.exists() || !folder.isDirectory()) { + return; + } + + File[] files = folder.listFiles(); + if (files == null) { + return; + } + + for (File file : files) { + if (file.isFile() && hasSupportedImageExtension(file.getName())) { + backgrounds.add(file.getName()); + } + } + } + + private Path resolveBackgroundPath(String filename) { + Path projectPath = Paths.get(PROJECT_BG_DIR, filename); + if (projectPath.toFile().isFile()) { + return projectPath; + } + + Path staticPath = Paths.get(STATIC_BG_DIR, filename); + if (staticPath.toFile().isFile()) { + return staticPath; + } + + return null; + } + + private boolean hasSupportedImageExtension(String filename) { + String lower = filename.toLowerCase(); + return lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png") || lower.endsWith(".webp"); + } + + private void addDashboardModelData(Model model, User user) { + model.addAttribute("user", user); + model.addAttribute("themes", List.of("default", "dark")); + model.addAttribute("linkStyles", List.of("pill", "box", "underline")); + model.addAttribute("textAlignments", List.of("center", "left")); + model.addAttribute("buttonColors", List.of("blue", "green", "red", "purple", "orange")); + model.addAttribute("fontFamilies", List.of("System", "Georgia", "Courier", "Arial")); + model.addAttribute("backgrounds", loadBackgrounds()); + } + + private String saveUploadFile(MultipartFile file, String filenamePrefix) throws IOException { + String uploadDir = STATIC_UPLOAD_DIR + "/"; + + File uploadFolder = new File(uploadDir); + if (!uploadFolder.exists()) { + uploadFolder.mkdirs(); + } + + String originalName = file.getOriginalFilename() == null ? "image" : Paths.get(file.getOriginalFilename()).getFileName().toString(); + String filename = filenamePrefix + System.currentTimeMillis() + "_" + originalName; + String filepath = uploadDir + filename; + + file.transferTo(new File(filepath)); + return filename; + } } \ No newline at end of file diff --git a/src/main/java/com/basecamp/HyprLink/controller/LandingPageController.java b/src/main/java/com/basecamp/HyprLink/controller/LandingPageController.java new file mode 100644 index 0000000..bf59a07 --- /dev/null +++ b/src/main/java/com/basecamp/HyprLink/controller/LandingPageController.java @@ -0,0 +1,29 @@ +package com.basecamp.HyprLink.controller; + +import com.basecamp.HyprLink.entity.User; +import com.basecamp.HyprLink.repository.UserRepository; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +import java.security.Principal; + +@Controller +public class LandingPageController { + + private final UserRepository userRepository; + public LandingPageController(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @GetMapping() + public String landingPage(Principal principal, Model model) { + if (principal == null) { + model.addAttribute("user", null); + return "index"; + } + User user = userRepository.findByUsername(principal.getName()).orElse(null); + model.addAttribute("user", user); + return "index"; + } +} diff --git a/src/main/java/com/basecamp/HyprLink/controller/ProfileController.java b/src/main/java/com/basecamp/HyprLink/controller/ProfileController.java index de930d8..b82ae60 100644 --- a/src/main/java/com/basecamp/HyprLink/controller/ProfileController.java +++ b/src/main/java/com/basecamp/HyprLink/controller/ProfileController.java @@ -1,39 +1,76 @@ package com.basecamp.HyprLink.controller; import com.basecamp.HyprLink.entity.User; -import com.basecamp.HyprLink.repository.UserRepository; +import com.basecamp.HyprLink.service.ProfileService; +import org.jspecify.annotations.NonNull; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.security.Principal; +import java.util.Optional; @Controller public class ProfileController { - private final UserRepository userRepository; + private final ProfileService profileService; - // Injecting the repository via the constructor - public ProfileController(UserRepository userRepository) { - this.userRepository = userRepository; + public ProfileController(ProfileService profileService) { + this.profileService = profileService; } - // The {id} is the dynamic part of the URL - @GetMapping("/profile/{id}") - public String getProfile(@PathVariable Long id, Model model) { - - // 1. Ask the database for the user matching the ID in the URL - User user = userRepository.findById(id).orElse(null); + @GetMapping("/profile/id/{id}") + public String getProfileById(@PathVariable Long id, Model model) { + User user = profileService.getUserProfileById(id); - // 2. If the user doesn't exist, you could route them to a 404 page if (user == null) { return "error/404"; } - // 3. Attach the found user object to the Thymeleaf model - // The string "user" must perfectly match the ${user.something} variables in your HTML model.addAttribute("user", user); + return "profile"; + } + @GetMapping("/profile") public String searchProfile(Principal principal, Model model) { + return processUserInfoByUsername(principal.getName(), principal, model); + } + @GetMapping("/profile/username/{userName}") + public String getProfileByUsername(@PathVariable String userName, Principal principal, Model model) { + return processUserInfoByUsername(userName, principal, model); + } + + // Helper Methods \\ + @NonNull + private String processUserInfoByUsername(String userName, Principal principal, Model model) { + User user = profileService.getUserProfileByUsername(userName); + return settingMethodAttributes(principal, model, user); + } + @NonNull + private String processUserInfoById(Long id, Principal principal, Model model) { + User user = profileService.getUserProfileById(id); + return settingMethodAttributes(principal, model, user); + } - // 4. Return the exact name of your Thymeleaf HTML file (without the .html extension) + @NonNull + private String settingMethodAttributes(Principal principal, Model model, User user) { + if (user == null) { + return "index"; + } + if (principal == null) { + model.addAttribute("signedIn", false); + model.addAttribute("principalName", null); + } else if (Optional.ofNullable(profileService.getUserProfileByUsername(principal.getName())).isPresent()) { + model.addAttribute("signedIn", true); + model.addAttribute("principalName", principal.getName()); + } + if (principal != null && user.getUsername().equals(principal.getName())) { + model.addAttribute("usersProfile", true); + } else { + model.addAttribute("usersProfile", false); + } + model.addAttribute("user", user); return "profile"; } } \ No newline at end of file diff --git a/src/main/java/com/basecamp/HyprLink/controller/TemplatesController.java b/src/main/java/com/basecamp/HyprLink/controller/TemplatesController.java new file mode 100644 index 0000000..c36bf03 --- /dev/null +++ b/src/main/java/com/basecamp/HyprLink/controller/TemplatesController.java @@ -0,0 +1,31 @@ +package com.basecamp.HyprLink.controller; + +import com.basecamp.HyprLink.entity.User; +import com.basecamp.HyprLink.repository.UserRepository; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +import java.security.Principal; + +@Controller +public class TemplatesController { + + private final UserRepository userRepository; + + public TemplatesController(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @GetMapping("/templates") + public String showTemplatesPage(Principal principal, Model model) { + if (principal == null) { + model.addAttribute("user", null); + } else { + User user = userRepository.findByUsername(principal.getName()).orElse(null); + model.addAttribute("user", user); + } + + return "templates"; + } +} \ No newline at end of file diff --git a/src/main/java/com/basecamp/HyprLink/entity/User.java b/src/main/java/com/basecamp/HyprLink/entity/User.java index 988a02a..6610094 100644 --- a/src/main/java/com/basecamp/HyprLink/entity/User.java +++ b/src/main/java/com/basecamp/HyprLink/entity/User.java @@ -24,6 +24,11 @@ public class User { private String bio; private String profilePicture; private String theme; + private String backgroundImage; + private String linkStyle; + private String textAlign; + private String buttonColor; + private String fontFamily; @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER) @JoinColumn(name = "user_id") diff --git a/src/main/java/com/basecamp/HyprLink/security/SecurityConfig.java b/src/main/java/com/basecamp/HyprLink/security/SecurityConfig.java index a37713a..2df1fd5 100644 --- a/src/main/java/com/basecamp/HyprLink/security/SecurityConfig.java +++ b/src/main/java/com/basecamp/HyprLink/security/SecurityConfig.java @@ -20,12 +20,12 @@ public PasswordEncoder passwordEncoder() { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth - .requestMatchers("/register", "/login", "/css/**", "/profile/**", "/").permitAll() // Public pages + .requestMatchers("/register/**", "/login", "/css/**", "/profile/**", "/", "/templates", "/images/**").permitAll() // Public pages .anyRequest().authenticated() // Everything else requires login ) .formLogin(form -> form .loginPage("/login") // Custom login page - .defaultSuccessUrl("/", true) // Where to go after successful login + .defaultSuccessUrl("/profile", true) // Where to go after successful login .permitAll() ) .logout(logout -> logout diff --git a/src/main/java/com/basecamp/HyprLink/service/AuthService.java b/src/main/java/com/basecamp/HyprLink/service/AuthService.java new file mode 100644 index 0000000..16e5a0e --- /dev/null +++ b/src/main/java/com/basecamp/HyprLink/service/AuthService.java @@ -0,0 +1,56 @@ +package com.basecamp.HyprLink.service; + +import com.basecamp.HyprLink.entity.SocialLink; +import com.basecamp.HyprLink.entity.User; +import com.basecamp.HyprLink.repository.UserRepository; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + + +import java.util.ArrayList; +import java.util.List; + +@Service +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + public User registerUser(User user) { + user.setPassword(passwordEncoder.encode(user.getPassword())); + return userRepository.save(user); + } + + public User prepareRegistrationFormData() { + User user = new User(); + List initialLinks = new ArrayList<>(); + initialLinks.add(new SocialLink()); + user.setSocialLinks(initialLinks); + return user; + } + + public Boolean checkUsername(String username) { + String trimmedUsername = username.trim(); + return trimmedUsername.equals(username); + } + + public Boolean checkUserDoesNotExist(String username) { + return userRepository.findByUsername(username).isPresent(); + } + + public Boolean checkPassword(String password) { + String trimmedPassword = password.trim(); + return trimmedPassword.equals(password); + } + + //This method can be expanded to fetch themes from the database in the future + public List getAvailableThemes() { + return List.of("default"); + } +} + diff --git a/src/main/java/com/basecamp/HyprLink/service/DashboardService.java b/src/main/java/com/basecamp/HyprLink/service/DashboardService.java new file mode 100644 index 0000000..3aa366f --- /dev/null +++ b/src/main/java/com/basecamp/HyprLink/service/DashboardService.java @@ -0,0 +1,62 @@ +package com.basecamp.HyprLink.service; + +import com.basecamp.HyprLink.entity.SocialLink; +import com.basecamp.HyprLink.entity.User; +import com.basecamp.HyprLink.repository.UserRepository; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class DashboardService { + + private final UserRepository userRepository; + + public DashboardService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + + public User getUserForDashboard(String username) { + User user = userRepository.findByUsername(username).orElse(null); + // Add a blank link slot at the end so the user always has room to add a new one + if (user != null) { + user.getSocialLinks().add(new SocialLink()); + } + return user; + } + + public User updateUserProfile(User updatedData, String username) { + User existingUser = userRepository.findByUsername(username).orElseThrow( + () -> new RuntimeException("User not found: " + username) + ); + + // Update basic fields + existingUser.setName(updatedData.getName()); + existingUser.setAge(updatedData.getAge()); + existingUser.setPronouns(updatedData.getPronouns()); + existingUser.setBio(updatedData.getBio()); + existingUser.setProfilePicture(updatedData.getProfilePicture()); + existingUser.setTheme(updatedData.getTheme()); + + // Update Social Links: Filter out any completely blank ones + if (updatedData.getSocialLinks() != null) { + List validLinks = updatedData.getSocialLinks().stream() + .filter(link -> link.getTitle() != null && !link.getTitle().isBlank() + && link.getUrl() != null && !link.getUrl().isBlank()) + .toList(); + + // Clear the old links and add the newly edited/filtered ones + existingUser.getSocialLinks().clear(); + existingUser.getSocialLinks().addAll(validLinks); + } + + return userRepository.save(existingUser); + } + + // Can be updated to match themes found in the database + public List getAvailableThemes() { + return List.of("default"); + } +} + diff --git a/src/main/java/com/basecamp/HyprLink/service/ProfileService.java b/src/main/java/com/basecamp/HyprLink/service/ProfileService.java new file mode 100644 index 0000000..fcb8b3b --- /dev/null +++ b/src/main/java/com/basecamp/HyprLink/service/ProfileService.java @@ -0,0 +1,24 @@ +package com.basecamp.HyprLink.service; + +import com.basecamp.HyprLink.entity.User; +import com.basecamp.HyprLink.repository.UserRepository; +import org.springframework.stereotype.Service; + +@Service +public class ProfileService { + + private final UserRepository userRepository; + + public ProfileService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public User getUserProfileById(Long id) { + return userRepository.findById(id).orElse(null); + } + + public User getUserProfileByUsername(String username) { + return userRepository.findByUsername(username).orElse(null); + } +} + diff --git a/src/main/resources/.DS_Store b/src/main/resources/.DS_Store new file mode 100644 index 0000000..15df738 Binary files /dev/null and b/src/main/resources/.DS_Store differ diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d02e779..75cfe20 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,7 +1,17 @@ spring.application.name=spring-unit-project -spring.datasource.url=jdbc:postgresql://localhost:5432/quest-log -spring.datasource.username=postgres -spring.datasource.password= +spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/link-garden} +spring.datasource.username=${DB_USERNAME:postgres} +spring.datasource.password=${DB_PASSWORD:Nf101308!} +spring.datasource.driver-class-name=org.postgresql.Driver -spring.jpa.hibernate.ddl-auto=create \ No newline at end of file +spring.jpa.hibernate.ddl-auto=update +spring.jpa.open-in-view=false + +server.port=${PORT:8080} + +spring.servlet.multipart.max-file-size=200MB +spring.servlet.multipart.max-request-size=200MB +server.tomcat.max-http-form-post-size=200MB +server.tomcat.max-swallow-size=200MB +spring.servlet.multipart.enabled=true diff --git a/src/main/resources/background templates/1.png b/src/main/resources/background templates/1.png new file mode 100644 index 0000000..411f906 Binary files /dev/null and b/src/main/resources/background templates/1.png differ diff --git a/src/main/resources/background templates/10.png b/src/main/resources/background templates/10.png new file mode 100644 index 0000000..93a87e6 Binary files /dev/null and b/src/main/resources/background templates/10.png differ diff --git a/src/main/resources/background templates/2.png b/src/main/resources/background templates/2.png new file mode 100644 index 0000000..f116555 Binary files /dev/null and b/src/main/resources/background templates/2.png differ diff --git a/src/main/resources/background templates/3.png b/src/main/resources/background templates/3.png new file mode 100644 index 0000000..ba466cd Binary files /dev/null and b/src/main/resources/background templates/3.png differ diff --git a/src/main/resources/background templates/4.png b/src/main/resources/background templates/4.png new file mode 100644 index 0000000..7c6b0a8 Binary files /dev/null and b/src/main/resources/background templates/4.png differ diff --git a/src/main/resources/background templates/5.png b/src/main/resources/background templates/5.png new file mode 100644 index 0000000..5f39dfe Binary files /dev/null and b/src/main/resources/background templates/5.png differ diff --git a/src/main/resources/background templates/6.png b/src/main/resources/background templates/6.png new file mode 100644 index 0000000..67e888c Binary files /dev/null and b/src/main/resources/background templates/6.png differ diff --git a/src/main/resources/background templates/7.png b/src/main/resources/background templates/7.png new file mode 100644 index 0000000..01aae85 Binary files /dev/null and b/src/main/resources/background templates/7.png differ diff --git a/src/main/resources/background templates/8.png b/src/main/resources/background templates/8.png new file mode 100644 index 0000000..0a530cb Binary files /dev/null and b/src/main/resources/background templates/8.png differ diff --git a/src/main/resources/background templates/9.png b/src/main/resources/background templates/9.png new file mode 100644 index 0000000..2423f3e Binary files /dev/null and b/src/main/resources/background templates/9.png differ diff --git a/src/main/resources/static/.DS_Store b/src/main/resources/static/.DS_Store new file mode 100644 index 0000000..8011c6f Binary files /dev/null and b/src/main/resources/static/.DS_Store differ diff --git a/src/main/resources/static/css/auth.css b/src/main/resources/static/css/auth.css index 0e63313..b3e9e6a 100644 --- a/src/main/resources/static/css/auth.css +++ b/src/main/resources/static/css/auth.css @@ -6,6 +6,107 @@ body { background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); } +/* NAVBAR STYLING */ +.nav { + align-items: center; + display: grid; + grid-template-columns: 1fr auto 1fr; + padding: 1rem 1rem; + border-bottom: 1px solid #e0e0e0; + background: linear-gradient(90deg, #fafbfc 0%, #f5f7fa 50%, #fafbfc 100%); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); +} + +.nav a { + text-decoration: none; + color: #1a1a2e; + font-size: 0.92rem; + transition: color 0.3s ease; +} + +.nav a:hover:not(#link-garden, #sign-up-btn) { + color: #3b82f6; +} + +.nav #link-garden { + font-weight: bold; + font-size: 1.1rem; + color: #1a1a2e; +} + +.nav-left, .nav-center, .nav-right { + display: flex; + align-items: center; +} + + +.nav-left a { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.nav-left svg { + width: 30px; + height: 30px; + display: block; +} + +.nav-center { + justify-content: center; + justify-self: center; +} + +.nav-center a { + font-size: 1rem; +} + +.nav-left { + justify-self: start; + margin-left: 10rem; +} + +.nav-right { + justify-self: end; + gap: 0.75rem; + margin-right: 10rem; +} + +.nav-right a { + display: inline-flex; + align-items: center; + line-height: 1; +} + +.nav-right button { + border: none; + background: none; + padding: 0 0 3px 0; +} +.nav-right button:hover { + color: #3b82f6; + cursor: pointer; +} + +#sign-up-btn { + padding: 0.5rem 1rem; + background: linear-gradient(135deg, #3b82f6 0%, #0ea5e9 100%); + color: white; + border: none; + border-radius: .5rem; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2); +} + +#sign-up-btn:hover { + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + transform: translateY(-1px); +} + + +/*Authentication CSS*/ + .auth-page { min-height: 100vh; display: flex; diff --git a/src/main/resources/static/css/dashboard.css b/src/main/resources/static/css/dashboard.css new file mode 100644 index 0000000..5c69d43 --- /dev/null +++ b/src/main/resources/static/css/dashboard.css @@ -0,0 +1,495 @@ +* { + box-sizing: border-box; +} + +body { + font-family: "Segoe UI", Arial, sans-serif; + margin: 0; + padding: 32px 360px 32px 32px; + color: #1f2b4d; + background: #f7faff; + max-width: 1200px; + margin-left: auto; + margin-right: auto; +} + +/* NAVBAR STYLING */ +.nav { + align-items: center; + display: grid; + grid-template-columns: 1fr auto 1fr; + padding: 1rem 1rem; + border-bottom: 1px solid #e0e0e0; + background: linear-gradient(90deg, #fafbfc 0%, #f5f7fa 50%, #fafbfc 100%); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); + align-self: stretch; +} + +.nav a { + text-decoration: none; + color: #1a1a2e; + font-size: 0.92rem; + transition: color 0.3s ease; +} + +.nav a:hover:not(#link-garden, #sign-up-btn) { + color: #3b82f6; +} + +.nav #link-garden { + font-weight: bold; + font-size: 1.1rem; + color: #1a1a2e; +} + +.nav-left, .nav-center, .nav-right { + display: flex; + align-items: center; +} + + +.nav-left a { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.nav-left svg { + width: 30px; + height: 30px; + display: block; +} + +.nav-center { + justify-content: center; + justify-self: center; +} + +.nav-center a { + font-size: 1rem; +} + +.nav-left { + justify-self: start; + margin-left: 10rem; +} + +.nav-right { + justify-self: end; + gap: 0.75rem; + margin-right: 10rem; +} + +.nav-right a { + display: inline-flex; + align-items: center; + line-height: 1; +} + +.nav-right button { + border: none; + background: none; + padding: 0 0 3px 0; +} +.nav-right button:hover { + color: #3b82f6; + cursor: pointer; +} + +#sign-up-btn { + padding: 0.5rem 1rem; + background: linear-gradient(135deg, #3b82f6 0%, #0ea5e9 100%); + color: white; + border: none; + border-radius: .5rem; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2); +} + +#sign-up-btn:hover { + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + transform: translateY(-1px); +} + + +h2 { + margin: 0 0 12px; + font-size: 2rem; +} + +h3 { + margin: 24px 0 10px; + font-size: 1.2rem; +} + +p { + color: #5f6d8c; + margin: 0 0 16px; +} + + +form { + max-width: 760px; + background: #ffffff; + border: 1px solid #dbe8ff; + border-radius: 16px; + padding: 20px; + box-shadow: 0 8px 24px rgba(40, 84, 160, 0.1); +} + +.tab-buttons { + display: flex; + gap: 8px; + margin-bottom: 14px; +} + +.tab-btn { + border: 1px solid #c4dafc; + background: #f3f8ff; + color: #335084; + border-radius: 999px; + padding: 8px 14px; + font-weight: 700; + cursor: pointer; +} + +.tab-btn.active { + background: #2a7de1; + color: #fff; + border-color: #2a7de1; +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +.background-grid { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin-top: 8px; +} + +.background-item { + width: 120px; + text-align: center; +} + +.background-thumb { + width: 100px; + height: 60px; + object-fit: cover; + border-radius: 8px; + border: 2px solid #c4dafc; + display: block; + margin: 6px auto; +} + +.background-name { + font-size: 0.75rem; + color: #5f6d8c; + word-break: break-word; +} + +.design-input { + display: block; + width: min(100%, 260px); + padding: 8px 10px; + margin: 6px 0 12px; + border: 1px solid #c4dafc; + border-radius: 8px; + background: #fff; + color: #1f2b4d; +} + +.file-input { + display: block; + margin: 8px 0 12px; + padding: 8px; + border: 1px solid #c4dafc; + border-radius: 8px; + background: #f3f8ff; + color: #1f2b4d; + font-size: 0.9rem; + cursor: pointer; +} + +.small-preview-pic { + width: 80px; + height: 80px; + border-radius: 8px; + object-fit: cover; + border: 2px solid #c4dafc; + display: block; + margin: 8px 0; +} + +.preview-card { + min-height: 520px; + max-height: 520px; + overflow-y: auto; + padding: 18px 16px 22px; + color: #28406b; +} + +.edit-row { + background: #f9fcff; + border: 1px solid #e3edff; + border-radius: 12px; + padding: 14px; + margin-bottom: 12px; +} + +.edit-row strong { + display: inline-block; + margin-bottom: 6px; + font-size: 0.9rem; + text-transform: uppercase; + color: #335084; +} + +.display-text { + display: inline-block; + margin-bottom: 8px; + margin-right: 10px; + color: #26395f; +} + +.hidden-input { + display: none; + padding: 8px 10px; + border: 1px solid #c4dafc; + border-radius: 8px; + background: #fff; + width: min(100%, 420px); + margin: 0 8px 8px 0; +} + +.bio-input { + width: min(100%, 560px); + min-height: 90px; + resize: vertical; +} + +.action-btn { + border: none; + border-radius: 999px; + padding: 8px 14px; + font-weight: 600; + cursor: pointer; + background: #2a7de1; + color: #fff; + margin-right: 6px; +} + +.action-btn:hover { + background: #1f69c4; +} + +.save-btn { + display: none; +} + +.edit-row div.hidden-input { + padding: 0; + border: none; + background: transparent; + width: 100%; +} + +.edit-row div.hidden-input input { + display: block; + width: min(100%, 560px); + padding: 8px 10px; + border: 1px solid #c4dafc; + border-radius: 8px; + margin: 8px 0; +} + +.success-message { + display: inline-block; + margin-bottom: 12px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid #bfeccd; + background: #e9fbf0; + color: #0f7a3f; + font-weight: 600; +} + +.field-error { + color: #d72f2f; + font-size: 0.9rem; + margin-bottom: 8px; +} + +.live-preview { + position: fixed; + right: 40px; + top: 70px; + width: 280px; + border-radius: 28px; + border: 2px solid #9fc6ff; + background: #fff; + box-shadow: 0 18px 35px rgba(21, 64, 134, 0.22); + overflow: hidden; +} + +.live-preview-header { + background: linear-gradient(90deg, #2a7de1, #2ec4ff); + color: #fff; + font-weight: 700; + padding: 14px 16px; + font-size: 1rem; +} + +.preview-card { + min-height: 520px; + max-height: 520px; + overflow-y: auto; + padding: 18px 16px 22px; + color: #28406b; +} + +.preview-profile-pic { + width: 70px; + height: 70px; + border-radius: 50%; + object-fit: cover; + border: 2px solid #fff; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16); + display: block; + margin: 0 auto 10px; +} + +.preview-card h4 { + margin: 0; + text-align: center; + font-size: 1.15rem; +} + +.preview-card p { + margin-top: 8px; + margin-bottom: 8px; + text-align: center; + color: #28406b; + font-weight: 600; +} + +.preview-card.text-align-center, +.preview-card.text-align-center h4, +.preview-card.text-align-center p, +.preview-card.text-align-center li { + text-align: center; +} + +.preview-card.text-align-left, +.preview-card.text-align-left h4, +.preview-card.text-align-left p, +.preview-card.text-align-left li { + text-align: left; +} + +.preview-links { + list-style: none; + padding: 0; + margin: 14px 0 0; +} + +.preview-links li { + margin-bottom: 10px; + text-align: center; +} + +.preview-links a { + display: block; + text-decoration: none; + color: #1f2b4d; + font-weight: 700; + background: rgba(255, 255, 255, 0.88); + border: 1px solid #dbe8ff; + border-radius: 999px; + padding: 10px 12px; +} + +.preview-card.link-style-pill .preview-links a { + border-radius: 999px; + background: rgba(255, 255, 255, 0.88); + border: 1px solid #dbe8ff; +} + +.preview-card.link-style-box .preview-links a { + border-radius: 10px; + background: rgba(233, 243, 255, 0.95); + border: 1px solid #b8d3ff; +} + +.preview-card.link-style-underline .preview-links a { + background: transparent; + border: none; + border-radius: 0; + text-decoration: underline; + padding-left: 0; + padding-right: 0; +} + +.preview-card.button-color-blue .preview-links a { + background: #2a7de1; + color: #fff; + border-color: #2a7de1; +} + +.preview-card.button-color-green .preview-links a { + background: #22c55e; + color: #fff; + border-color: #22c55e; +} + +.preview-card.button-color-red .preview-links a { + background: #ef4444; + color: #fff; + border-color: #ef4444; +} + +.preview-card.button-color-purple .preview-links a { + background: #a855f7; + color: #fff; + border-color: #a855f7; +} + +.preview-card.button-color-orange .preview-links a { + background: #f97316; + color: #fff; + border-color: #f97316; +} + +.preview-card.font-family-system { + font-family: "Segoe UI", Arial, sans-serif; +} + +.preview-card.font-family-georgia { + font-family: Georgia, serif; +} + +.preview-card.font-family-courier { + font-family: "Courier New", monospace; +} + +.preview-card.font-family-arial { + font-family: Arial, sans-serif; +} + +@media (max-width: 1050px) { + body { + padding: 24px; + } + + .live-preview { + display: none; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/default.css b/src/main/resources/static/css/default.css index 0a11a7e..6d5584d 100644 --- a/src/main/resources/static/css/default.css +++ b/src/main/resources/static/css/default.css @@ -10,11 +10,127 @@ body { min-height: 100vh; } +/* NAVBAR STYLING */ +.nav { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + align-items: center; + display: grid; + grid-template-columns: 1fr auto 1fr; + padding: 1rem 1rem; + border-bottom: 1px solid #e0e0e0; + background: linear-gradient(90deg, #fafbfc 0%, #f5f7fa 50%, #fafbfc 100%); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); + align-self: stretch; +} + +.nav a { + text-decoration: none; + color: #1a1a2e; + font-size: 0.92rem; + transition: color 0.3s ease; +} + +.nav a:hover:not(#link-garden, #sign-up-btn) { + color: #3b82f6; +} + +.nav #link-garden { + font-weight: bold; + font-size: 1.1rem; + color: #1a1a2e; +} + +.nav-left, .nav-center, .nav-right { + display: flex; + align-items: center; +} + +.nav-center { + justify-content: center; + justify-self: center; +} + +.nav-center a { + font-size: 1rem; +} + +.nav-left { + justify-self: start; + margin-left: 10rem; +} + +.nav-right { + justify-self: end; + gap: 0.75rem; + margin-right: 10rem; +} + +.nav-right a { + display: inline-flex; + align-items: center; + line-height: 1; +} + +.nav-right button { + border: none; + background: none; + padding: 0 0 3px 0; +} +.nav-right button:hover { + color: #3b82f6; + cursor: pointer; +} + +#sign-up-btn { + padding: 0.5rem 1rem; + background: linear-gradient(135deg, #3b82f6 0%, #0ea5e9 100%); + color: white; + border: none; + border-radius: .5rem; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2); +} + +#sign-up-btn:hover { + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + transform: translateY(-1px); +} + .profile-container { max-width: 450px; width: 100%; text-align: center; margin-top: 50px; + border-radius: 16px; + padding: 22px 18px; +} + +.profile-container.card-style-default { + background: rgba(255, 255, 255, 0.82); +} + +.profile-container.card-style-glass { + background: rgba(255, 255, 255, 0.66); + backdrop-filter: blur(6px); +} + +.profile-container.card-style-solid { + background: #eef5ff; +} + +.profile-container.text-align-left, +.profile-container.text-align-left .name, +.profile-container.text-align-left .demographics, +.profile-container.text-align-left .bio { + text-align: left; +} + +.profile-container.text-align-center, +.profile-container.text-align-center .name, +.profile-container.text-align-center .demographics, +.profile-container.text-align-center .bio { + text-align: center; } .profile-pic { @@ -66,6 +182,29 @@ body { box-shadow: 0 2px 4px rgba(0,0,0,0.02); } +.profile-container.link-style-pill .link-button { + border-radius: 999px; + border: 1px solid #e0e0e0; + background-color: #ffffff; + text-decoration: none; +} + +.profile-container.link-style-box .link-button { + border-radius: 10px; + border: 1px solid #b8d3ff; + background-color: #eaf3ff; + text-decoration: none; +} + +.profile-container.link-style-underline .link-button { + border: none; + box-shadow: none; + border-radius: 0; + background: transparent; + padding: 8px 0; + text-decoration: underline; +} + .link-button:hover { border-color: #007bff; transform: translateY(-2px); diff --git a/src/main/resources/static/css/landing.css b/src/main/resources/static/css/landing.css index 5f5e289..f46a2ab 100644 --- a/src/main/resources/static/css/landing.css +++ b/src/main/resources/static/css/landing.css @@ -18,6 +18,9 @@ body { border-bottom: 1px solid #e0e0e0; background: linear-gradient(90deg, #fafbfc 0%, #f5f7fa 50%, #fafbfc 100%); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); + width: 100%; + left: 0; + margin: 0; } .nav a { @@ -33,7 +36,7 @@ body { .nav #link-garden { font-weight: bold; - font-size: 1.1rem; + font-size: 1.2rem; color: #1a1a2e; } @@ -42,13 +45,32 @@ body { align-items: center; } + +.nav-left a { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.nav-left svg { + width: 30px; + height: 30px; + display: block; +} + .nav-center { justify-content: center; justify-self: center; + gap: 1rem; + align-items: center; } -.nav-center a { - font-size: 1rem; +.nav-center a, .nav-center button { + font-size: 1.2rem; +} + +.nav button:hover { + color: #3b82f6; } .nav-left { @@ -66,6 +88,21 @@ body { display: inline-flex; align-items: center; line-height: 1; + font-size: 1.2rem; +} + +.nav-right #logout-link:hover { + text-decoration: underline; +} + +.nav-center button, .nav-right button { + border: none; + background: none; + padding: 0 0 3px 0; +} +.nav-center button:hover, .nav-right button:hover { + color: #3b82f6; + cursor: pointer; } #sign-up-btn { @@ -751,119 +788,6 @@ footer { - -/* SIGN UP / LOGIN PAGES */ -.signup-section, -.login-section { - display: flex; - padding: 2rem 1rem 8rem 1rem; - flex-direction: column; - align-items: center; - text-align: center; - margin-top: 4rem; -} - - -.signup-form, -.login-form { - padding: 4rem; - display: flex; - flex-direction: column; - gap: 1rem; - justify-content: center; - width: min(90vw, 390px); - min-height: 29rem; - border: 2px solid #dbeafe; - border-radius: 1rem; - background: linear-gradient(135deg, #f8fbff 0%, #eef7ff 100%); - box-shadow: 0 16px 36px rgba(59, 130, 246, 0.12); - text-align: center; - margin: 0; -} - -.login-form { - padding-top: 0; -} - -.signup-form h1, -.login-form h1 { - margin: 0; - font-size: 1.5rem; - color: #1a1a2e; -} - -.signup-form h2 a, -.login-form h2 a { - margin: 0; - font-size: 1.75rem; - color: #0f172a; - text-decoration: none; -} - -.signup-form h2 a:hover, -.login-form h2 a:hover { - color: #3b82f6; -} - -.signup-form p, -.login-form p { - color: #475569; - margin: 0; -} - -.signup-form input, -.login-form input { - padding: 0.75rem; - width: 100%; - box-sizing: border-box; - font-size: 1rem; - border: 1px solid #cbd5e1; - border-radius: .5rem; - background-color: #ffffff; - transition: border-color 0.2s ease, box-shadow 0.2s ease; -} - -.signup-form input:focus, -.login-form input:focus { - outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); -} - -.signup-form #signup-btn, -.login-form #login-btn { - background: linear-gradient(135deg, #3b82f6 0%, #0ea5e9 100%); - color: white; - border: none; - border-radius: .5rem; - padding: 0.75rem 1.5rem; - font-size: 1rem; - cursor: pointer; - transition: transform 0.2s ease, box-shadow 0.2s ease; -} - -.signup-form #signup-btn:hover, -.login-form #login-btn:hover { - transform: translateY(-1px); - box-shadow: 0 8px 20px rgba(59, 130, 246, 0.25); -} - -.signup-form > a, -.login-form > a { - color: #1e293b; - font-size: 0.875rem; - text-decoration: none; -} - -.signup-form > a:hover, -.login-form > a:hover { - color: #3b82f6; - text-decoration: underline; -} - - - - /* MOBILE FRIENDLY STYLING */ @media (max-width: 900px) { .nav-left { diff --git a/src/main/resources/static/css/login.css b/src/main/resources/static/css/login.css new file mode 100644 index 0000000..c1bb931 --- /dev/null +++ b/src/main/resources/static/css/login.css @@ -0,0 +1,332 @@ +/* LOGIN PAGE */ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + min-height: 100vh; + background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); +} + +/* NAVBAR STYLING */ +.nav { + align-items: center; + display: grid; + grid-template-columns: 1fr auto 1fr; + padding: 1rem 1rem; + border-bottom: 1px solid #e0e0e0; + background: linear-gradient(90deg, #fafbfc 0%, #f5f7fa 50%, #fafbfc 100%); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); +} + +.nav a { + text-decoration: none; + color: #1a1a2e; + font-size: 0.92rem; + transition: color 0.3s ease; +} + +.nav a:hover:not(#link-garden, #sign-up-btn) { + color: #3b82f6; +} + +.nav #link-garden { + font-weight: bold; + font-size: 1.2rem; + color: #1a1a2e; +} + +.nav-left, .nav-center, .nav-right { + display: flex; + align-items: center; +} + + +.nav-left a { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.nav-left svg { + width: 30px; + height: 30px; + display: block; +} + +.nav-center { + justify-content: center; + justify-self: center; +} + +.nav-center a { + font-size: 1.2rem; +} + +.nav-left { + justify-self: start; + margin-left: 10rem; +} + +.nav-right { + justify-self: end; + gap: 0.75rem; + margin-right: 10rem; +} + +.nav-right a { + display: inline-flex; + align-items: center; + line-height: 1; + font-size: 1.2rem; +} + +.nav-right #logout-link:hover { + text-decoration: underline; +} + +.nav-right button { + border: none; + background: none; + padding: 0 0 3px 0; +} +.nav-right button:hover { + color: #3b82f6; + cursor: pointer; +} + +#sign-up-btn { + padding: 0.5rem 1rem; + background: linear-gradient(135deg, #3b82f6 0%, #0ea5e9 100%); + color: white; + border: none; + border-radius: .5rem; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2); +} + +#sign-up-btn:hover { + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + transform: translateY(-1px); +} + +/*Login CSS*/ + +.login-section { + display: flex; + padding: 2rem 1rem 8rem 1rem; + flex-direction: column; + align-items: center; + text-align: center; + margin-top: 4rem; +} + + +.login-form { + padding: 4rem; + display: flex; + flex-direction: column; + gap: 1rem; + justify-content: center; + width: min(90vw, 390px); + min-height: 29rem; + border: 2px solid #dbeafe; + border-radius: 1rem; + background: linear-gradient(135deg, #f8fbff 0%, #eef7ff 100%); + box-shadow: 0 16px 36px rgba(59, 130, 246, 0.12); + text-align: center; + margin: 0; +} + +.login-form p { + padding-bottom: 2rem; +} + +.login-form h1 { + margin: 0; + font-size: 1.5rem; + color: #1a1a2e; +} + +.login-form h2 a { + margin: 0; + font-size: 1.75rem; + color: #0f172a; + text-decoration: none; +} + +.login-form h2 a:hover { + color: #3b82f6; +} + +.login-form p { + color: #475569; + margin: 0; +} + +.login-form input { + padding: 0.75rem; + width: 100%; + box-sizing: border-box; + font-size: 1rem; + border: 1px solid #cbd5e1; + border-radius: .5rem; + background-color: #ffffff; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.login-form input:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +.login-form #login-btn { + background: linear-gradient(135deg, #3b82f6 0%, #0ea5e9 100%); + color: white; + border: none; + border-radius: .5rem; + padding: 0.75rem 1.5rem; + font-size: 1rem; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.login-form #login-btn:hover { + transform: translateY(-1px); + box-shadow: 0 8px 20px rgba(59, 130, 246, 0.25); +} + +.login-form > a { + color: #1e293b; + font-size: 0.875rem; + text-decoration: none; +} + +.login-form > a:hover { + color: #3b82f6; + text-decoration: underline; +} + + + + +/* FOOTER SECTION */ +footer { + background-color: #f0f3f6; + text-align: center; + padding: 1rem; + font-size: 0.875rem; + color: #555; + margin-top: auto; + clear: both; + border-top: 1px solid #ccc; +} + + + + +/* MOBILE FRIENDLY STYLING */ +@media (max-width: 420px) { + .hero h1, + .hero h2 { + font-size: 1.95rem; + } + + .login-section { + display: flex; + align-items: center; + justify-content: center; + padding: 3rem 1rem; + } + .login-form { + box-sizing: border-box; + margin: 0.5rem auto; + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + @media (max-width: 900px) { + .login-section { + padding: 2rem 1rem; + } + .login-form { + width: 92%; + max-width: 520px; + padding: 1.25rem; + } + .login-form h1 { + font-size: 1.6rem; + } + + .login-form h2 a { + font-size: 1.15rem; + } + .nav-left { + margin-left: 1rem; + } + .nav-right { + margin-right: 1rem; + } + .login-form input, + .login-form button { + width: 100%; + padding: 0.75rem 0.9rem; + font-size: 1rem; + border-radius: 0.5rem; + } + } + + @media (max-width: 520px) { + .nav { + grid-template-columns: 1fr; + row-gap: 0.5rem; + padding: 0.75rem; + } + .nav-left, .nav-center, .nav-right { + justify-content: center; + } + .nav-left { + margin-left: 0; + } + .nav-right { + margin-right: 0; + gap: 0.5rem; + } + + .login-form { + width: 100%; + max-width: 100%; + margin: 0.75rem; + padding: 1rem; + border-radius: 12px; + gap: 0.5rem; + } + .login-form input, + .login-form button { + padding: 0.9rem; + font-size: 1.05rem; + width: 100%; + } + .hero { + margin-top: 2.5rem; + padding: 1rem; + } + footer { + font-size: 0.95rem; + padding: 1rem; + } + } + + .template-options { + flex-direction: column; + gap: 1rem; + } + + .template-option .card { + width: 100%; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/nav.css b/src/main/resources/static/css/nav.css new file mode 100644 index 0000000..18fa7a1 --- /dev/null +++ b/src/main/resources/static/css/nav.css @@ -0,0 +1,111 @@ +/* NAVBAR STYLING */ +.nav { + align-items: center; + display: grid; + grid-template-columns: 1fr auto 1fr; + padding: 1rem 1rem; + border-bottom: 1px solid #e0e0e0; + background: linear-gradient(90deg, #fafbfc 0%, #f5f7fa 50%, #fafbfc 100%); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); + width: 100%; + left: 0; + margin: 0; +} + +.nav a { + text-decoration: none; + color: #1a1a2e; + font-size: 0.92rem; + transition: color 0.3s ease; +} + +.nav a:hover:not(#link-garden, #sign-up-btn) { + color: #3b82f6; +} + +.nav #link-garden { + font-weight: bold; + font-size: 1.2rem; + color: #1a1a2e; +} + +.nav-left, .nav-center, .nav-right { + display: flex; + align-items: center; +} + + +.nav-left a { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.nav-left svg { + width: 30px; + height: 30px; + display: block; +} + +.nav-center { + justify-content: center; + justify-self: center; + gap: 1rem; + align-items: center; +} + +.nav-center a, .nav-center button { + font-size: 1.2rem; +} + +.nav button:hover { + color: #3b82f6; +} + +.nav-left { + justify-self: start; + margin-left: 10rem; +} + +.nav-right { + justify-self: end; + gap: 0.75rem; + margin-right: 10rem; +} + +.nav-right a { + display: inline-flex; + align-items: center; + line-height: 1; + font-size: 1.2rem; +} + +.nav-right #logout-link:hover { + text-decoration: underline; +} + +.nav-center button, .nav-right button { + border: none; + background: none; + padding: 0 0 3px 0; +} +.nav-center button:hover, .nav-right button:hover { + color: #3b82f6; + cursor: pointer; +} + +#sign-up-btn { + padding: 0.5rem 1rem; + background: linear-gradient(135deg, #3b82f6 0%, #0ea5e9 100%); + color: white; + border: none; + border-radius: .5rem; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2); +} + +#sign-up-btn:hover { + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + transform: translateY(-1px); +} \ No newline at end of file diff --git a/src/main/resources/static/css/register.css b/src/main/resources/static/css/register.css new file mode 100644 index 0000000..f1a2dcd --- /dev/null +++ b/src/main/resources/static/css/register.css @@ -0,0 +1,377 @@ +/* REGISTER PAGE */ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + min-height: 100vh; + background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); +} + +/* FOOTER SECTION */ +footer { + background-color: #f0f3f6; + text-align: center; + padding: 1rem; + font-size: 0.875rem; + color: #555; + margin-top: auto; + clear: both; + border-top: 1px solid #ccc; +} + +/* NAVBAR STYLING */ +.nav { + align-items: center; + display: grid; + grid-template-columns: 1fr auto 1fr; + padding: 1rem 1rem; + border-bottom: 1px solid #e0e0e0; + background: linear-gradient(90deg, #fafbfc 0%, #f5f7fa 50%, #fafbfc 100%); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); +} + +.nav a { + text-decoration: none; + color: #1a1a2e; + font-size: 0.92rem; + transition: color 0.3s ease; +} + +.nav a:hover:not(#link-garden, #sign-up-btn) { + color: #3b82f6; +} + +.nav #link-garden { + font-weight: bold; + font-size: 1.2rem; + color: #1a1a2e; +} + +.nav-left, .nav-center, .nav-right { + display: flex; + align-items: center; +} + + +.nav-left a { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.nav-left svg { + width: 30px; + height: 30px; + display: block; +} + +.nav-center { + justify-content: center; + justify-self: center; +} + +.nav-center a { + font-size: 1.2rem; +} + +.nav-left { + justify-self: start; + margin-left: 10rem; +} + +.nav-right { + justify-self: end; + gap: 0.75rem; + margin-right: 10rem; +} + +.nav-right a { + display: inline-flex; + align-items: center; + line-height: 1; + font-size: 1.2rem; +} + +.nav-right #logout-link:hover { + text-decoration: underline; +} + +.nav-right button { + border: none; + background: none; + padding: 0 0 3px 0; +} +.nav-right button:hover { + color: #3b82f6; + cursor: pointer; +} + +#sign-up-btn { + padding: 0.5rem 1rem; + background: linear-gradient(135deg, #3b82f6 0%, #0ea5e9 100%); + color: white; + border: none; + border-radius: .5rem; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2); +} + +#sign-up-btn:hover { + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + transform: translateY(-1px); +} + +/*Message Error CSS*/ +.auth-message { + border-radius: 0.6rem; + padding: 0.7rem 0.8rem; + font-size: 0.94rem; + margin-bottom: 0.75rem; +} + +.auth-message-error { + background: #fee2e2; + color: #991b1b; + border: 1px solid #fecaca; +} + +/* SIGN UP PAGE */ +.signup-section { + display: flex; + padding: 2rem 1rem 8rem 1rem; + flex-direction: column; + align-items: center; + text-align: center; + margin-top: 4rem; +} + + +.signup-form { + padding: 4rem; + display: flex; + flex-direction: column; + gap: 1rem; + justify-content: center; + width: min(90vw, 390px); + min-height: 29rem; + border: 2px solid #dbeafe; + border-radius: 1rem; + background: linear-gradient(135deg, #f8fbff 0%, #eef7ff 100%); + box-shadow: 0 16px 36px rgba(59, 130, 246, 0.12); + text-align: center; + margin: 0; +} + +.signup-form h1 { + margin: 0; + font-size: 1.5rem; + color: #1a1a2e; +} + +.signup-form h2 a { + margin: 0; + font-size: 1.75rem; + color: #0f172a; + text-decoration: none; +} + +.signup-form h2 a:hover { + color: #3b82f6; +} + +.signup-form p { + color: #475569; + margin: 0; +} + +.signup-form input { + padding: 0.75rem; + width: 100%; + box-sizing: border-box; + font-size: 1rem; + border: 1px solid #cbd5e1; + border-radius: .5rem; + background-color: #ffffff; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.signup-form input:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +.signup-form select { + padding: 0.75rem; + width: 100%; + box-sizing: border-box; + font-size: 1rem; + border: 1px solid #cbd5e1; + border-radius: .5rem; + background-color: #ffffff; +} + +.signup-form #signup-btn { + background: linear-gradient(135deg, #3b82f6 0%, #0ea5e9 100%); + color: white; + border: none; + border-radius: .5rem; + padding: 0.75rem 1.5rem; + font-size: 1rem; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.signup-form #signup-btn:hover { + transform: translateY(-1px); + box-shadow: 0 8px 20px rgba(59, 130, 246, 0.25); +} + + +.signup-form #add-link-btn { + background: transparent; + border: solid; + border-color: #0f172a; + border-radius: .5rem; + padding: 0.75rem; + width: 50%; + align-self: center; + font-size: 1rem; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.signup-form #add-link-btn:hover { + box-shadow: 0 8px 20px rgba(59, 130, 246, 0.25); +} + +.signup-form > a { + color: #1e293b; + font-size: 0.875rem; + text-decoration: none; +} + +.signup-form > a:hover { + color: #3b82f6; + text-decoration: underline; +} + + + +/* MOBILE FRIENDLY STYLING */ +@media (max-width: 420px) { + .hero h1, + .hero h2 { + font-size: 1.95rem; + } + + .signup-section { + display: flex; + align-items: center; + justify-content: center; + padding: 3rem 1rem; + } + + .signup-form { + box-sizing: border-box; + margin: 0.5rem auto; + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + @media (max-width: 900px) { + .signup-section { + padding: 2rem 1rem; + } + + .signup-form { + width: 92%; + max-width: 520px; + padding: 1.25rem; + } + + .signup-form h1 { + font-size: 1.6rem; + } + + .signup-form h2 a { + font-size: 1.15rem; + } + + .nav-left { + margin-left: 1rem; + } + + .nav-right { + margin-right: 1rem; + } + + .signup-form input, + .signup-form button { + width: 100%; + padding: 0.75rem 0.9rem; + font-size: 1rem; + border-radius: 0.5rem; + } + } + + @media (max-width: 520px) { + .nav { + grid-template-columns: 1fr; + row-gap: 0.5rem; + padding: 0.75rem; + } + + .nav-left, .nav-center, .nav-right { + justify-content: center; + } + + .nav-left { + margin-left: 0; + } + + .nav-right { + margin-right: 0; + gap: 0.5rem; + } + + .signup-form { + width: 100%; + max-width: 100%; + margin: 0.75rem; + padding: 1rem; + border-radius: 12px; + gap: 0.5rem; + } + + .signup-form input, + .signup-form button { + padding: 0.9rem; + font-size: 1.05rem; + width: 100%; + } + + .hero { + margin-top: 2.5rem; + padding: 1rem; + } + + footer { + font-size: 0.95rem; + padding: 1rem; + } + } + + .template-options { + flex-direction: column; + gap: 1rem; + } + + .template-option .card { + width: 100%; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/templates.css b/src/main/resources/static/css/templates.css new file mode 100644 index 0000000..7d859e1 --- /dev/null +++ b/src/main/resources/static/css/templates.css @@ -0,0 +1,324 @@ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + min-height: 100vh; + background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); +} + + +/* NAVBAR STYLING */ +.nav { + align-items: center; + display: grid; + grid-template-columns: 1fr auto 1fr; + padding: 1rem 1rem; + border-bottom: 1px solid #e0e0e0; + background: linear-gradient(90deg, #fafbfc 0%, #f5f7fa 50%, #fafbfc 100%); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); + width: 100%; + left: 0; + margin: 0; +} + +.nav a { + text-decoration: none; + color: #1a1a2e; + font-size: 0.92rem; + transition: color 0.3s ease; +} + +.nav a:hover:not(#link-garden, #sign-up-btn) { + color: #3b82f6; +} + +.nav #link-garden { + font-weight: bold; + font-size: 1.2rem; + color: #1a1a2e; +} + +.nav-left, .nav-center, .nav-right { + display: flex; + align-items: center; +} + + +.nav-left a { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.nav-left svg { + width: 30px; + height: 30px; + display: block; +} + +.nav-center { + justify-content: center; + justify-self: center; + gap: 1rem; + align-items: center; +} + +.nav-center a, .nav-center button { + font-size: 1.2rem; +} + +.nav button:hover { + color: #3b82f6; +} + +.nav-left { + justify-self: start; + margin-left: 10rem; +} + +.nav-right { + justify-self: end; + gap: 0.75rem; + margin-right: 10rem; +} + +.nav-right a { + display: inline-flex; + align-items: center; + line-height: 1; + font-size: 1.2rem; +} + +.nav-right #logout-link:hover { + text-decoration: underline; +} + +.nav-center button, .nav-right button { + border: none; + background: none; + padding: 0 0 3px 0; +} +.nav-center button:hover, .nav-right button:hover { + color: #3b82f6; + cursor: pointer; +} + +#sign-up-btn { + padding: 0.5rem 1rem; + background: linear-gradient(135deg, #3b82f6 0%, #0ea5e9 100%); + color: white; + border: none; + border-radius: .5rem; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2); +} + +#sign-up-btn:hover { + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + transform: translateY(-1px); +} + + + + +/* TEMPLATE PREVIEW STYLING */ +.intro { + text-align: center; + padding: 2rem 1rem; + margin-top: 2rem; + gap: 1rem; + display: flex; + flex-direction: column; +} + +.intro h1 { + margin: 0; + font-size: 2.5rem; + color: #1a1a2e; +} + +.intro p { + font-size: 1.2rem; + color: #555; + margin: 0; +} + +.template-preview { + width: max-content; + margin: 2.5rem auto; + margin-top: 0; + position: relative; + z-index: 3; + display: grid; + grid-template-columns: repeat(3, auto); + row-gap: 0.0625rem; + column-gap: 0.5rem; + align-items: start; + justify-items: center; + padding: 0; +} + +.template-preview img { + width: 20rem; + min-width: 20rem; + max-width: 20rem; + height: 40rem; + min-height: 40rem; + max-height: 40rem; + object-fit: cover; + display: block; + margin: 2rem; + border: none; + border-radius: 40px; + background: transparent; +} + +.template-preview img:hover { + transform: scale(1.01); + box-shadow: 0 0 12px rgba(56, 189, 248, 0.42); +} + + +/* INTRO STYLING */ +.hero { + text-align: center; + padding: 2rem 1rem; + min-height: 75dvh; + position: relative; + overflow: hidden; + background: white; +} + +.network-bg { + position: absolute; + top: calc(6rem); + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + z-index: 0; +} + +.network-bg::before { + content: ""; + position: absolute; + inset: 0; + background-image: radial-gradient(rgba(59, 130, 246, 0.16) 1px, transparent 1px); + background-size: 32px 32px; + opacity: 0.32; +} + +.network-bg::after { + content: ""; + position: absolute; + inset: 0; + background: + radial-gradient(circle at 6% 12%, rgba(37, 99, 235, 0.32) 0 2px, transparent 3px), + radial-gradient(circle at 14% 56%, rgba(14, 165, 233, 0.28) 0 2px, transparent 3px), + radial-gradient(circle at 22% 28%, rgba(37, 99, 235, 0.3) 0 2px, transparent 3px), + radial-gradient(circle at 31% 73%, rgba(56, 189, 248, 0.26) 0 2px, transparent 3px), + radial-gradient(circle at 39% 18%, rgba(37, 99, 235, 0.3) 0 2px, transparent 3px), + radial-gradient(circle at 47% 49%, rgba(59, 130, 246, 0.3) 0 2px, transparent 3px), + radial-gradient(circle at 55% 24%, rgba(14, 165, 233, 0.26) 0 2px, transparent 3px), + radial-gradient(circle at 63% 66%, rgba(37, 99, 235, 0.32) 0 2px, transparent 3px), + radial-gradient(circle at 72% 38%, rgba(56, 189, 248, 0.26) 0 2px, transparent 3px), + radial-gradient(circle at 81% 14%, rgba(37, 99, 235, 0.3) 0 2px, transparent 3px), + radial-gradient(circle at 88% 58%, rgba(59, 130, 246, 0.28) 0 2px, transparent 3px), + radial-gradient(circle at 94% 32%, rgba(14, 165, 233, 0.26) 0 2px, transparent 3px); + opacity: 0.72; +} + +.net-node { + position: absolute; + width: 11px; + height: 11px; + border-radius: 999px; + background: rgba(30, 64, 175, 0.72); + box-shadow: 0 0 12px rgba(56, 189, 248, 0.42); + animation: node-pulse 4.5s ease-in-out infinite, node-float 7s ease-in-out infinite; + opacity: 0.9; + transform: translateY(0.1rem); +} + +.node-a { top: 24%; left: 6%; animation-delay: -1s; } +.node-b { top: 42%; left: 4%; animation-delay: -3s; } +.node-c { top: 46%; left: 96%; animation-delay: -2s; } +.node-d { top: 68%; left: 8%; animation-delay: -4s; } +.node-e { top: 82%; left: 22%; animation-delay: -2.8s; } +.node-f { top: 28%; left: 84%; animation-delay: -1.6s; } +.node-g { top: 62%; left: 90%; animation-delay: -4.8s; } +.node-h { top: 12%; left: 2%; animation-delay: -1.2s; } +.node-i { top: 14%; left: 92%; animation-delay: -3.4s; } +.node-j { top: 30%; left: 18%; animation-delay: -2.2s; } +.node-k { top: 38%; left: 70%; animation-delay: -5.1s; } +.node-l { top: 46%; left: 14%; animation-delay: -0.8s; } +.node-m { top: 54%; left: 2%; animation-delay: -4.1s; } +.node-n { top: 66%; left: 88%; animation-delay: -2.9s; } +.node-o { top: 74%; left: 10%; animation-delay: -5.5s; } +.node-p { top: 80%; left: 50%; animation-delay: -1.7s; } +.node-q { top: 92%; left: 86%; animation-delay: -3.8s; } + +.hero-content { + position: relative; + z-index: 2; + animation: fadeInDown 0.8s ease-out; +} + +@keyframes fadeInDown { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes node-pulse { + 0% { transform: scale(0.9); opacity: 0.45; } + 50% { transform: scale(1.28); opacity: 0.8; } + 100% { transform: scale(1); opacity: 0.45; } +} + +@keyframes node-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6px); } +} + +@keyframes pulse-glow { + 0%, 100% { + box-shadow: 0 8px 24px rgba(59, 130, 246, 0.1); + } + 50% { + box-shadow: 0 8px 24px rgba(59, 130, 246, 0.2); + } +} + +/* FOOTER STYLING */ +footer { + background-color: #f0f3f6; + text-align: center; + padding: 1rem; + font-size: 0.875rem; + color: #555; + margin-top: auto; + clear: both; + border-top: 1px solid #ccc; +} + + +/* MOBILE RESPONSIVENESS */ +@media (max-width: 900px) { + .template-preview { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 520px) { + .template-preview { + grid-template-columns: 1fr; + } +} diff --git a/src/main/resources/static/images/.DS_Store b/src/main/resources/static/images/.DS_Store new file mode 100644 index 0000000..7827d35 Binary files /dev/null and b/src/main/resources/static/images/.DS_Store differ diff --git a/src/main/resources/static/images/blue.png b/src/main/resources/static/images/blue.png new file mode 100644 index 0000000..321eda1 Binary files /dev/null and b/src/main/resources/static/images/blue.png differ diff --git a/src/main/resources/static/images/brown.png b/src/main/resources/static/images/brown.png new file mode 100644 index 0000000..5313045 Binary files /dev/null and b/src/main/resources/static/images/brown.png differ diff --git a/src/main/resources/static/images/default.png b/src/main/resources/static/images/default.png new file mode 100644 index 0000000..b65b2a3 Binary files /dev/null and b/src/main/resources/static/images/default.png differ diff --git a/src/main/resources/static/images/green.png b/src/main/resources/static/images/green.png new file mode 100644 index 0000000..590c68b Binary files /dev/null and b/src/main/resources/static/images/green.png differ diff --git a/src/main/resources/static/images/kitchen.png b/src/main/resources/static/images/kitchen.png new file mode 100644 index 0000000..6ddd656 Binary files /dev/null and b/src/main/resources/static/images/kitchen.png differ diff --git a/src/main/resources/static/images/pink.png b/src/main/resources/static/images/pink.png new file mode 100644 index 0000000..054ebd7 Binary files /dev/null and b/src/main/resources/static/images/pink.png differ diff --git a/src/main/resources/static/images/plant.png b/src/main/resources/static/images/plant.png new file mode 100644 index 0000000..882f475 Binary files /dev/null and b/src/main/resources/static/images/plant.png differ diff --git a/src/main/resources/static/images/pottery.png b/src/main/resources/static/images/pottery.png new file mode 100644 index 0000000..d765a8a Binary files /dev/null and b/src/main/resources/static/images/pottery.png differ diff --git a/src/main/resources/static/images/red.png b/src/main/resources/static/images/red.png new file mode 100644 index 0000000..d7c0cdb Binary files /dev/null and b/src/main/resources/static/images/red.png differ diff --git a/src/main/resources/templates/auth/login.html b/src/main/resources/templates/auth/login.html index 9baa711..6069002 100644 --- a/src/main/resources/templates/auth/login.html +++ b/src/main/resources/templates/auth/login.html @@ -2,37 +2,61 @@ - Login | Link Garden - + Login | HyperLink + + +
-
-

Welcome back

-

Log in to manage your Link Garden profile.

+ - - +
+

© 2026 HyperLink. All rights reserved.

+
-

Don't have an account? Sign up here.

-
\ No newline at end of file diff --git a/src/main/resources/templates/auth/register.html b/src/main/resources/templates/auth/register.html index 505efc7..6e21117 100644 --- a/src/main/resources/templates/auth/register.html +++ b/src/main/resources/templates/auth/register.html @@ -2,95 +2,133 @@ - Sign Up | Link Garden - + Sign Up | HyperLink + + +
-
-

Create your account

-

Set up your profile and add the links you want to share.

-
- - +
-
+ + + + + + + Already have an account? Log in + + + + + + + + +
+

© 2026 HyperLink. All rights reserved.

+
+ \ No newline at end of file diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 5df8add..8d5bf39 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -3,147 +3,374 @@ Dashboard | Link Garden - + +

Your Dashboard

-
+
Profile updated successfully!
-

Click "Edit" next to any field to change it.

+
+ Upload is too large. Please choose a smaller image. +
-
+

Click "Edit" next to any field to change it.

-
- Display Name:
- Name - + - - - +
+ +
-
- Age:
- Age - +
+
+ Profile Picture:
+ Profile Picture + +

Or paste image URL:

+ + + +
+ +
+ Display Name:
+ Name + +
- - - -
+ + +
-
- Pronouns:
- Pronouns - +
+ Age:
+ Age + - - - -
+ + +
-
- Bio:
- Bio - +
+ Pronouns:
+ Pronouns + - - - -
+ + +
-
- Profile Picture URL:
- Profile Picture URL - +
+ Bio:
+ Bio + - - - -
+ + +
-
- Theme:
- Theme +
+ Theme:
+ Theme - + - - - -
+ + +
+ +

Your Links

+

Edit existing links, or fill out the new one at the bottom to add more.

-

Your Links

-

Edit existing links, or fill out the new one at the bottom to add more.

+
+ Link :
-
- Link :
+ - + + Title - + URL + - - Title - - URL - +
+ + +
-
- - + +
+
+ +
+
+ Profile Background:
+ +

Or choose from templates:

+
+
+ +
+
+
+
+ +
+ Link Style:
+ - - - + Text Alignment:
+ + + Button Color:
+ + + Font:
+ + + +
+
+
Preview
+ +
+ \ No newline at end of file diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 4439aa1..feb2058 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -1,5 +1,5 @@ - + @@ -9,19 +9,35 @@
@@ -49,10 +65,8 @@

Your Personal

Hyperlink Page

Bring all your links, content, and profiles together into one simple page you can share anywhere.

- - - - + +
@@ -73,6 +87,7 @@

Hyperlink Page

+ Templates
@@ -164,7 +179,7 @@

One link to share

-

© 2026 Hyperlink. All rights reserved.

+

© 2026 HyperLink. All rights reserved.

diff --git a/src/main/resources/templates/profile.html b/src/main/resources/templates/profile.html index 52f00da..e449ecb 100644 --- a/src/main/resources/templates/profile.html +++ b/src/main/resources/templates/profile.html @@ -3,28 +3,70 @@ + Link Garden Profile +
- Profile Picture + +
+ Profile Picture -

Default Name

+

Default Name

-

- AgePronouns -

+

+ AgePronouns +

-

Default bio text goes here.

+

Default bio text goes here.

- + +
\ No newline at end of file diff --git a/src/main/resources/templates/templates.html b/src/main/resources/templates/templates.html new file mode 100644 index 0000000..23c009e --- /dev/null +++ b/src/main/resources/templates/templates.html @@ -0,0 +1,93 @@ + + + + + + + Template Previews + + + + + +
+

See What You Can Create

+

Browse sample pages for ideas, then build a layout that fits you.

+
+ +
+ + + + +
+ + Template 1 Preview + Template 2 Preview + Template 8 Preview + Template 3 Preview + Template 7 Preview + Template 4 Preview + Template 5 Preview + Template 9 Preview + Template 6 Preview +
+ +
+ +
+

© 2026 HyperLink. All rights reserved.

+
+ + + + \ No newline at end of file diff --git a/src/test/java/com/basecamp/HyprLink/controller/AuthControllerTest.java b/src/test/java/com/basecamp/HyprLink/controller/AuthControllerTest.java new file mode 100644 index 0000000..6c8de15 --- /dev/null +++ b/src/test/java/com/basecamp/HyprLink/controller/AuthControllerTest.java @@ -0,0 +1,94 @@ +package com.basecamp.HyprLink.controller; + +import com.basecamp.HyprLink.entity.User; +import com.basecamp.HyprLink.service.AuthService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.Model; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AuthController Tests") +class AuthControllerTest { + + @Mock + private AuthService authService; + + private AuthController authController; + + @BeforeEach + void setUp() { + authController = new AuthController(authService); + } + + // ==================== Login Endpoint Tests ==================== + + @Test + @DisplayName("Should return login view for GET /login") + void testShowLoginForm_ReturnsLoginView() { + // Act + String viewName = authController.showLoginForm(); + + // Assert + assertThat(viewName).isEqualTo("auth/login"); + } + + // ==================== Registration Page Endpoint Tests ==================== + + @Test + @DisplayName("Should return register view and populate model for GET /register") + void testShowRegistrationForm_PopulatesModelAndReturnsRegisterView() { + // Arrange + Model model = new ExtendedModelMap(); + User formUser = new User(); + List themes = List.of("default"); + + when(authService.prepareRegistrationFormData()).thenReturn(formUser); + when(authService.getAvailableThemes()).thenReturn(themes); + + // Act + String viewName = authController.showRegistrationForm(model); + + // Assert + assertThat(viewName).isEqualTo("auth/register"); + assertThat(model.getAttribute("user")).isSameAs(formUser); + assertThat(model.getAttribute("themes")).isEqualTo(themes); + verify(authService).prepareRegistrationFormData(); + verify(authService).getAvailableThemes(); + } + + // ==================== Registration Submit Endpoint Tests ==================== + + @Test + @DisplayName("Should register user and redirect to login success for POST /register") + void testRegisterUser_CallsServiceAndRedirects() { + // Arrange + User incomingUser = new User(); + incomingUser.setUsername("johndoe"); + incomingUser.setPassword("plain-password"); + + // Act + String redirect = authController.registerUser(incomingUser); + + // Assert + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(authService).registerUser(userCaptor.capture()); + + User captured = userCaptor.getValue(); + assertThat(captured.getUsername()).isEqualTo("johndoe"); + assertThat(captured.getPassword()).isEqualTo("plain-password"); + assertThat(redirect).isEqualTo("redirect:/login?success"); + } +} + diff --git a/src/test/java/com/basecamp/HyprLink/controller/DashboardControllerTest.java b/src/test/java/com/basecamp/HyprLink/controller/DashboardControllerTest.java new file mode 100644 index 0000000..c2f7d4a --- /dev/null +++ b/src/test/java/com/basecamp/HyprLink/controller/DashboardControllerTest.java @@ -0,0 +1,140 @@ +package com.basecamp.HyprLink.controller; + +import com.basecamp.HyprLink.entity.SocialLink; +import com.basecamp.HyprLink.entity.User; +import com.basecamp.HyprLink.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.Model; + +import java.io.IOException; +import java.security.Principal; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("DashboardController Tests") +class DashboardControllerTest { + + @Mock + private UserRepository userRepository; + + @Mock + private Principal principal; + + private DashboardController dashboardController; + + @BeforeEach + void setUp() { + dashboardController = new DashboardController(userRepository); + } + + // ==================== Dashboard View Endpoint Tests ==================== + + @Test + @DisplayName("Should return dashboard view, append blank link, and populate model for GET /dashboard") + void testShowDashboard_UserExists_PopulatesModelAndReturnsDashboardView() { + // Arrange + Model model = new ExtendedModelMap(); + User dashboardUser = new User(); + dashboardUser.setUsername("johndoe"); + + SocialLink existingLink = new SocialLink(); + existingLink.setTitle("Portfolio"); + existingLink.setUrl("https://example.com"); + dashboardUser.setSocialLinks(new ArrayList<>(List.of(existingLink))); + + when(principal.getName()).thenReturn("johndoe"); + when(userRepository.findByUsername("johndoe")).thenReturn(Optional.of(dashboardUser)); + + // Act + String viewName = dashboardController.showDashboard(principal, model); + + // Assert + assertThat(viewName).isEqualTo("dashboard"); + assertThat(model.getAttribute("user")).isSameAs(dashboardUser); + assertThat(model.getAttribute("themes")).isEqualTo(List.of("default", "dark")); + assertThat(model.getAttribute("linkStyles")).isEqualTo(List.of("pill", "box", "underline")); + assertThat(model.getAttribute("textAlignments")).isEqualTo(List.of("center", "left")); + assertThat(model.getAttribute("buttonColors")).isEqualTo(List.of("blue", "green", "red", "purple", "orange")); + assertThat(model.getAttribute("fontFamilies")).isEqualTo(List.of("System", "Georgia", "Courier", "Arial")); + assertThat(model.getAttribute("backgrounds")).isInstanceOf(List.class); + assertThat(dashboardUser.getSocialLinks()).hasSize(2); + assertThat(dashboardUser.getSocialLinks().get(1).getTitle()).isNull(); + } + + // ==================== Dashboard Save Endpoint Tests ==================== + + @Test + @DisplayName("Should save profile updates, filter blank links, and redirect for POST /dashboard/save") + void testSaveProfile_ValidUpdates_FiltersLinksAndRedirects() throws IOException { + // Arrange + User existingUser = new User(); + existingUser.setUsername("johndoe"); + existingUser.setSocialLinks(new ArrayList<>(List.of(new SocialLink()))); + + User updatedData = new User(); + updatedData.setName("New Name"); + updatedData.setAge("24"); + updatedData.setPronouns("they/them"); + updatedData.setBio("New bio"); + updatedData.setTheme("dark"); + updatedData.setLinkStyle("pill"); + updatedData.setTextAlign("center"); + updatedData.setButtonColor("purple"); + updatedData.setFontFamily("Georgia"); + updatedData.setProfilePicture("https://example.com/avatar.png"); + updatedData.setBackgroundImage("/images/background-templates/1.png"); + + SocialLink validLink = new SocialLink(); + validLink.setTitle("GitHub"); + validLink.setUrl("https://github.com/johndoe"); + + SocialLink blankLink = new SocialLink(); + blankLink.setTitle(" "); + blankLink.setUrl(" "); + + updatedData.setSocialLinks(List.of(validLink, blankLink)); + + when(principal.getName()).thenReturn("johndoe"); + when(userRepository.findByUsername("johndoe")).thenReturn(Optional.of(existingUser)); + + // Act + String redirect = dashboardController.saveProfile(updatedData, principal, null, null); + + // Assert + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(userCaptor.capture()); + + User captured = userCaptor.getValue(); + assertThat(captured.getName()).isEqualTo("New Name"); + assertThat(captured.getBio()).isEqualTo("New bio"); + assertThat(captured.getTheme()).isEqualTo("dark"); + assertThat(captured.getSocialLinks()).hasSize(1); + assertThat(captured.getSocialLinks().get(0).getTitle()).isEqualTo("GitHub"); + assertThat(redirect).isEqualTo("redirect:/dashboard?success"); + } + + // ==================== Exception Handler Tests ==================== + + @Test + @DisplayName("Should redirect to dashboard with uploadTooLarge flag when upload exceeds limit") + void testHandleUploadTooLarge_RedirectsWithFlag() { + // Act + String redirect = dashboardController.handleUploadTooLarge(); + + // Assert + assertThat(redirect).isEqualTo("redirect:/dashboard?uploadTooLarge"); + } +} diff --git a/src/test/java/com/basecamp/HyprLink/controller/ProfileControllerTest.java b/src/test/java/com/basecamp/HyprLink/controller/ProfileControllerTest.java new file mode 100644 index 0000000..6f50d6f --- /dev/null +++ b/src/test/java/com/basecamp/HyprLink/controller/ProfileControllerTest.java @@ -0,0 +1,87 @@ +package com.basecamp.HyprLink.controller; + +import com.basecamp.HyprLink.entity.User; +import com.basecamp.HyprLink.service.ProfileService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.Model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ProfileController Tests") +class ProfileControllerTest { + + @Mock + private ProfileService profileService; + + private ProfileController profileController; + + @BeforeEach + void setUp() { + profileController = new ProfileController(profileService); + } + + // ==================== Public Profile Endpoint Tests ==================== + + @Test + @DisplayName("Should return profile view and add user to model for GET /profile/{id}") + void testGetProfile_UserExists_ReturnsProfileView() { + // Arrange + Model model = new ExtendedModelMap(); + User user = new User(); + user.setId(1L); + user.setUsername("johndoe"); + user.setName("John Doe"); + + when(profileService.getUserProfileById(1L)).thenReturn(user); + + // Act + String viewName = profileController.getProfileById(1L, model); + + // Assert + assertThat(viewName).isEqualTo("profile"); + assertThat(model.getAttribute("user")).isSameAs(user); + verify(profileService).getUserProfileById(1L); + } + + @Test + @DisplayName("Should return 404 view when profile is not found for GET /profile/{id}") + void testGetProfile_UserMissing_Returns404View() { + // Arrange + Model model = new ExtendedModelMap(); + when(profileService.getUserProfileById(999L)).thenReturn(null); + + // Act + String viewName = profileController.getProfileById(999L, model); + + // Assert + assertThat(viewName).isEqualTo("error/404"); + assertThat(model.containsAttribute("user")).isFalse(); + verify(profileService).getUserProfileById(999L); + } + + @Test + @DisplayName("Should return 404 view when profile lookup is called with null id") + void testGetProfile_NullId_Returns404View() { + // Arrange + Model model = new ExtendedModelMap(); + when(profileService.getUserProfileById(null)).thenReturn(null); + + // Act + String viewName = profileController.getProfileById(null, model); + + // Assert + assertThat(viewName).isEqualTo("error/404"); + assertThat(model.containsAttribute("user")).isFalse(); + verify(profileService).getUserProfileById(null); + } +} + diff --git a/src/test/java/com/basecamp/HyprLink/controller/TEST_DOCUMENTATION.md b/src/test/java/com/basecamp/HyprLink/controller/TEST_DOCUMENTATION.md new file mode 100644 index 0000000..5540904 --- /dev/null +++ b/src/test/java/com/basecamp/HyprLink/controller/TEST_DOCUMENTATION.md @@ -0,0 +1,149 @@ +# Controller Layer Test Suite Documentation + +## Overview +The controller-layer test suite verifies request-handling behavior for `AuthController`, `DashboardController`, and `ProfileController`. + +This suite includes **9 unit tests** that validate: +- returned view names +- model attribute population +- redirects for form submissions +- repository/service delegation +- edge-case controller flows + +## Test Configuration +- **Framework:** JUnit 5 (Jupiter) +- **Testing Library:** AssertJ (Fluent Assertions) +- **Mocking:** Mockito (`@ExtendWith(MockitoExtension.class)`) +- **Model Type:** `ExtendedModelMap` for controller model assertions +- **Style:** Arrange-Act-Assert (AAA) + +## Test Organization + +### AuthController Tests (3 tests) + +#### 1. testShowLoginForm_ReturnsLoginView +- **Purpose:** Verify login endpoint returns correct view +- **Scenario:** Invoke `showLoginForm()` +- **Assertions:** + - Returned view is `auth/login` + +#### 2. testShowRegistrationForm_PopulatesModelAndReturnsRegisterView +- **Purpose:** Verify register page model setup +- **Scenario:** Invoke `showRegistrationForm(model)` with mocked service data +- **Assertions:** + - Returned view is `auth/register` + - Model contains `user` and `themes` + - Auth service methods are called + +#### 3. testRegisterUser_CallsServiceAndRedirects +- **Purpose:** Verify register submit flow +- **Scenario:** Invoke `registerUser(user)` +- **Assertions:** + - Auth service is called with incoming user data + - Redirect is `redirect:/login?success` + +### DashboardController Tests (3 tests) + +#### 4. testShowDashboard_UserExists_PopulatesModelAndReturnsDashboardView +- **Purpose:** Verify dashboard page model setup for authenticated user +- **Scenario:** Invoke `showDashboard(principal, model)` with existing user from repository +- **Assertions:** + - Returned view is `dashboard` + - Model contains `user` and dashboard customization collections + - Controller appends one blank social-link row for dashboard editing + +#### 5. testSaveProfile_ValidUpdates_FiltersLinksAndRedirects +- **Purpose:** Verify dashboard save submission behavior +- **Scenario:** Invoke `saveProfile(updatedData, principal, null, null)` +- **Assertions:** + - User fields are updated from posted model data + - Invalid blank links are filtered out before save + - Redirect is `redirect:/dashboard?success` + +#### 6. testHandleUploadTooLarge_RedirectsWithFlag +- **Purpose:** Verify upload exception redirect behavior +- **Scenario:** Invoke `handleUploadTooLarge()` +- **Assertions:** + - Redirect is `redirect:/dashboard?uploadTooLarge` + +### ProfileController Tests (3 tests) + +#### 7. testGetProfile_UserExists_ReturnsProfileView +- **Purpose:** Verify public profile page for existing user +- **Scenario:** Invoke `getProfile(id, model)` with found user +- **Assertions:** + - Returned view is `profile` + - Model contains `user` + - Profile service is called with expected id + +#### 8. testGetProfile_UserMissing_Returns404View +- **Purpose:** Verify 404 flow for missing profile +- **Scenario:** Invoke `getProfile(id, model)` with null service result +- **Assertions:** + - Returned view is `error/404` + - Model does not contain `user` + +#### 9. testGetProfile_NullId_Returns404View +- **Purpose:** Verify null-id edge case behavior +- **Scenario:** Invoke `getProfile(null, model)` +- **Assertions:** + - Returned view is `error/404` + - Model does not contain `user` + - Service lookup is still invoked + +## Key Testing Strategies + +### 1. Controller Isolation +- Dependencies are mocked to isolate controller responsibilities +- Tests focus on MVC behavior (view/model/redirect) and delegation + +### 2. View + Model Validation +- Each GET endpoint validates both returned view and model content +- Missing-data paths verify fallback behavior (null model user or 404 view) + +### 3. Delegation Verification +- POST flows verify repository/service calls with expected inputs +- Principal-based methods verify username propagation + +### 4. Consistent Test Style +- `@DisplayName` is used for all tests +- Method names follow `testMethod_Scenario_Expected` +- Tests use clear AAA comments and section banners + +## Running the Tests + +### Run all controller tests: +```bash +./mvnw test -Dtest=AuthControllerTest,DashboardControllerTest,ProfileControllerTest +``` + +### Run one controller test class: +```bash +./mvnw test -Dtest=DashboardControllerTest +``` + +### Run a specific test method: +```bash +./mvnw test -Dtest=DashboardControllerTest#testHandleUploadTooLarge_RedirectsWithFlag +``` + +## Test Results + +✅ All 9 controller tests pass successfully +- **Failures:** 0 +- **Errors:** 0 +- **Skipped:** 0 + +## Coverage Summary + +The suite covers: +- ✅ Auth page routing and registration redirect flow +- ✅ Dashboard view-model composition, save filtering, and upload exception redirect handling +- ✅ Profile page success and 404 branches +- ✅ Principal username propagation for dashboard endpoints +- ✅ Null/missing edge cases in controller logic + +## Future Enhancements +- Add MockMvc tests for endpoint-level HTTP assertions +- Add tests for multipart upload success paths +- Add tests for validation/error feedback once controller validation is introduced diff --git a/src/test/java/com/basecamp/HyprLink/repository/TEST_DOCUMENTATION.md b/src/test/java/com/basecamp/HyprLink/repository/TEST_DOCUMENTATION.md new file mode 100644 index 0000000..e6b2c76 --- /dev/null +++ b/src/test/java/com/basecamp/HyprLink/repository/TEST_DOCUMENTATION.md @@ -0,0 +1,219 @@ +# UserRepository Test Suite Documentation + +## Overview +The `UserRepositoryTest` class provides comprehensive test coverage for the `UserRepository` interface, testing both custom query methods and inherited JPA methods. The test suite includes 17 test cases covering CRUD operations, custom queries, edge cases, and data validation. + +## Test Configuration +- **Framework:** JUnit 5 (Jupiter) +- **Testing Library:** AssertJ (Fluent Assertions) +- **Spring Boot Testing:** @SpringBootTest +- **Database:** H2 (in-memory for testing) +- **Transaction Management:** @Transactional (auto-rollback between tests) +- **Test Profile:** @ActiveProfiles("test") + +## Test Organization + +### Custom Query Tests (4 tests) + +#### 1. testFindByUsername_Success +- **Purpose:** Verify that a user can be found by exact username match +- **Scenario:** Save a user, then retrieve it by username +- **Assertions:** + - User is present in Optional + - Username matches + - Associated data (name, age) is correctly retrieved + +#### 2. testFindByUsername_NotFound +- **Purpose:** Verify that non-existent usernames return empty Optional +- **Scenario:** Query for a username that doesn't exist +- **Assertions:** + - Optional is empty + - No exception is thrown + +#### 3. testFindByUsername_CaseSensitive +- **Purpose:** Verify that username search is case-sensitive +- **Scenario:** Save user "johndoe1", search for "Johndoe1" (different case) +- **Assertions:** + - Optional is empty (case mismatch results in no match) + +#### 4. testFindByUsername_MultipleUsers +- **Purpose:** Verify correct user is returned when multiple users exist +- **Scenario:** Create multiple users, query for specific user +- **Assertions:** + - Correct user is returned with matching data + - Other users are not returned + +### JpaRepository Inherited Tests (10 tests) + +#### 5. testSave_NewUser +- **Purpose:** Verify that a new user can be saved to the database +- **Scenario:** Save a new User entity +- **Assertions:** + - User receives an ID after save + - All fields are persisted correctly + +#### 6. testSave_UserWithSocialLinks +- **Purpose:** Verify that cascading save works for associated SocialLinks +- **Scenario:** Save user with 2 social links +- **Assertions:** + - User and all social links are persisted + - Links can be retrieved with user data + - Link count and data match what was saved + +#### 7. testFindById_Success +- **Purpose:** Verify user retrieval by primary key +- **Scenario:** Save user, then find by ID +- **Assertions:** + - User is found + - ID matches + - All user data is correct + +#### 8. testFindById_NotFound +- **Purpose:** Verify that invalid IDs return empty Optional +- **Scenario:** Query for non-existent ID +- **Assertions:** + - Optional is empty + +#### 9. testFindAll +- **Purpose:** Verify retrieval of all users in database +- **Scenario:** Create 3 users, retrieve all +- **Assertions:** + - All 3 users are returned + - All usernames are present + +#### 10. testSave_UpdateExistingUser +- **Purpose:** Verify that existing users can be updated +- **Scenario:** Save user, modify fields, save again +- **Assertions:** + - ID remains the same + - Updated fields reflect new values + - Update is persisted + +#### 11. testDeleteById +- **Purpose:** Verify deletion by ID +- **Scenario:** Save user, delete by ID +- **Assertions:** + - User no longer exists in database + - Subsequent query returns empty Optional + +#### 12. testDelete +- **Purpose:** Verify deletion of entity +- **Scenario:** Save user, delete entity +- **Assertions:** + - User no longer exists + - ID cannot be found + +#### 13. testExistsById +- **Purpose:** Verify existence checking +- **Scenario:** Check if saved user exists, and non-existent user doesn't +- **Assertions:** + - Existing user returns true + - Non-existent ID returns false + +#### 14. testCount +- **Purpose:** Verify counting total users +- **Scenario:** Create 2 users, count all +- **Assertions:** + - Count is at least 2 + +### Edge Cases & Data Integrity Tests (3 tests) + +#### 15. testSave_NullSocialLinks +- **Purpose:** Verify that users can be saved with null social links +- **Scenario:** Save user with socialLinks = null +- **Assertions:** + - User saves successfully + - ID is generated + +#### 16. testSave_EmptySocialLinks +- **Purpose:** Verify that users can be saved with empty social links list +- **Scenario:** Save user with empty ArrayList of links +- **Assertions:** + - User persists + - Links list is empty on retrieval + +#### 17. testUpdate_SocialLinks +- **Purpose:** Verify that social links can be added to existing user +- **Scenario:** Save user, add new social link, save again +- **Assertions:** + - New link is persisted + - All 3 links exist after update + - Link titles are correct + +## Key Testing Strategies + +### 1. Data Isolation +- Each test calls `deleteAll()` in `@BeforeEach` to ensure clean state +- Unique usernames generated using test counter to prevent constraint violations + +### 2. Transaction Management +- `@Transactional` annotation auto-rolls back changes after each test +- `entityManager.flush()` forces persistence operations +- `entityManager.clear()` clears the persistence context to verify database state + +### 3. Assertion Patterns +- **AssertJ Fluent API** for readable assertions +- **Lambda-based assertions** for complex validations +- **Exact value checks** for critical fields +- **Collection assertions** for related data + +### 4. Test Data +- Consistent test user created in `@BeforeEach` +- Sample social links with realistic data +- Clear field values for easy debugging + +## Running the Tests + +### Run all UserRepository tests: +```bash +./mvnw test -Dtest=UserRepositoryTest +``` + +### Run a specific test: +```bash +./mvnw test -Dtest=UserRepositoryTest#testFindByUsername_Success +``` + +### Run all tests (including others): +```bash +./mvnw test +``` + +## Test Results + +✅ All 17 tests pass successfully +- 0 failures +- 0 skipped +- Average execution time: ~2.4 seconds +- Database: H2 in-memory + +## Code Coverage + +The test suite covers: +- ✅ Custom finder method (`findByUsername`) +- ✅ CRUD operations (Create, Read, Update, Delete) +- ✅ Query methods (findById, findAll, existsById, count) +- ✅ Cascading relationships (User → SocialLinks) +- ✅ Edge cases (null values, empty collections) +- ✅ Data integrity (persistence, retrieval) + +## Best Practices Demonstrated + +1. **Descriptive Test Names** - Test method names clearly describe what is being tested +2. **@DisplayName Annotations** - Provides human-readable test descriptions +3. **Arrange-Act-Assert Pattern** - Clear test structure with setup, execution, verification +4. **Single Responsibility** - Each test focuses on one behavior +5. **No Test Dependencies** - Tests run independently in any order +6. **Meaningful Assertions** - Clear messages when assertions fail +7. **Cleanup** - Each test starts with a clean database state + +## Future Enhancements + +Potential additions to expand test coverage: +- Test for duplicate username constraint violations +- Test for required field validation (null username/password) +- Test for password field constraints +- Performance tests for large datasets +- Transaction isolation level testing +- Custom query with multiple parameters (if added) + diff --git a/src/test/java/com/basecamp/HyprLink/repository/UserRepositoryTest.java b/src/test/java/com/basecamp/HyprLink/repository/UserRepositoryTest.java new file mode 100644 index 0000000..e4df8b9 --- /dev/null +++ b/src/test/java/com/basecamp/HyprLink/repository/UserRepositoryTest.java @@ -0,0 +1,386 @@ +package com.basecamp.HyprLink.repository; + +import com.basecamp.HyprLink.entity.SocialLink; +import com.basecamp.HyprLink.entity.User; +import jakarta.persistence.EntityManager; +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.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +@DisplayName("UserRepository Tests") +class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private EntityManager entityManager; + + private User testUser; + private static int testCounter = 0; + + @BeforeEach + void setUp() { + // Clear data before each test to avoid unique constraint violations + testCounter++; + userRepository.deleteAll(); + entityManager.flush(); + + // Create a test user with sample data using unique username + testUser = new User(); + testUser.setUsername("johndoe" + testCounter); + testUser.setPassword("hashedpassword123"); + testUser.setName("John Doe"); + testUser.setAge("30"); + testUser.setPronouns("he/him"); + testUser.setBio("Test user bio"); + testUser.setProfilePicture("https://example.com/avatar.jpg"); + testUser.setTheme("default"); + + // Add sample social links + SocialLink link1 = new SocialLink(); + link1.setTitle("Portfolio"); + link1.setUrl("https://example.com/portfolio"); + + SocialLink link2 = new SocialLink(); + link2.setTitle("Twitter"); + link2.setUrl("https://twitter.com/johndoe"); + + testUser.setSocialLinks(new ArrayList<>(List.of(link1, link2))); + } + + // ==================== Custom Query Tests ==================== + + @Test + @DisplayName("Should find user by username when user exists") + void testFindByUsername_Success() { + // Arrange + userRepository.save(testUser); + entityManager.flush(); + + // Act + Optional foundUser = userRepository.findByUsername(testUser.getUsername()); + + // Assert + assertThat(foundUser) + .isPresent() + .hasValueSatisfying(user -> { + assertThat(user.getUsername()).isEqualTo(testUser.getUsername()); + assertThat(user.getName()).isEqualTo("John Doe"); + assertThat(user.getAge()).isEqualTo("30"); + }); + } + + @Test + @DisplayName("Should return empty Optional when user does not exist") + void testFindByUsername_NotFound() { + // Act + Optional foundUser = userRepository.findByUsername("nonexistentuser"); + + // Assert + assertThat(foundUser).isEmpty(); + } + + @Test + @DisplayName("Should be case-sensitive when finding by username") + void testFindByUsername_CaseSensitive() { + // Arrange + userRepository.save(testUser); + entityManager.flush(); + + // Act - Try with different case + String username = testUser.getUsername(); + String differentCaseUsername = username.substring(0, 1).toUpperCase() + username.substring(1); + Optional foundUser = userRepository.findByUsername(differentCaseUsername); + + // Assert + assertThat(foundUser).isEmpty(); + } + + @Test + @DisplayName("Should return correct user when multiple users exist") + void testFindByUsername_MultipleUsers() { + // Arrange + User user1 = new User(); + user1.setUsername("alice" + testCounter); + user1.setPassword("password123"); + user1.setName("Alice"); + + User user2 = new User(); + user2.setUsername("bob" + testCounter); + user2.setPassword("password456"); + user2.setName("Bob"); + + userRepository.save(user1); + userRepository.save(user2); + entityManager.flush(); + + // Act + Optional foundUser = userRepository.findByUsername(user1.getUsername()); + + // Assert + assertThat(foundUser) + .isPresent() + .hasValueSatisfying(user -> { + assertThat(user.getName()).isEqualTo("Alice"); + assertThat(user.getUsername()).isEqualTo(user1.getUsername()); + }); + } + + // ==================== JpaRepository Inherited Tests ==================== + + @Test + @DisplayName("Should save a new user to database") + void testSave_NewUser() { + // Act + User savedUser = userRepository.save(testUser); + entityManager.flush(); + + // Assert + assertThat(savedUser.getId()).isNotNull(); + assertThat(savedUser.getUsername()).isEqualTo(testUser.getUsername()); + assertThat(savedUser.getName()).isEqualTo("John Doe"); + } + + @Test + @DisplayName("Should save user with associated social links") + void testSave_UserWithSocialLinks() { + // Act + User savedUser = userRepository.save(testUser); + entityManager.flush(); + entityManager.clear(); + + // Retrieve to verify persistence + Optional retrievedUser = userRepository.findById(savedUser.getId()); + + // Assert + assertThat(retrievedUser) + .isPresent() + .hasValueSatisfying(user -> { + assertThat(user.getSocialLinks()).hasSize(2); + assertThat(user.getSocialLinks()) + .extracting(SocialLink::getTitle) + .containsExactly("Portfolio", "Twitter"); + }); + } + + @Test + @DisplayName("Should find user by ID") + void testFindById_Success() { + // Arrange + User savedUser = userRepository.save(testUser); + entityManager.flush(); + + // Act + Optional foundUser = userRepository.findById(savedUser.getId()); + + // Assert + assertThat(foundUser) + .isPresent() + .hasValueSatisfying(user -> { + assertThat(user.getId()).isEqualTo(savedUser.getId()); + assertThat(user.getUsername()).isEqualTo(testUser.getUsername()); + }); + } + + @Test + @DisplayName("Should return empty Optional when finding user by non-existent ID") + void testFindById_NotFound() { + // Act + Optional foundUser = userRepository.findById(9999L); + + // Assert + assertThat(foundUser).isEmpty(); + } + + @Test + @DisplayName("Should find all users in database") + void testFindAll() { + // Arrange + User user1 = new User(); + user1.setUsername("user1" + testCounter); + user1.setPassword("pass1"); + + User user2 = new User(); + user2.setUsername("user2" + testCounter); + user2.setPassword("pass2"); + + userRepository.save(user1); + userRepository.save(user2); + userRepository.save(testUser); + entityManager.flush(); + + // Act + List allUsers = userRepository.findAll(); + + // Assert + assertThat(allUsers).hasSize(3); + assertThat(allUsers) + .extracting(User::getUsername) + .containsExactlyInAnyOrder(user1.getUsername(), user2.getUsername(), testUser.getUsername()); + } + + @Test + @DisplayName("Should update existing user") + void testSave_UpdateExistingUser() { + // Arrange + User savedUser = userRepository.save(testUser); + entityManager.flush(); + + // Act - Update the user + savedUser.setName("Jane Doe"); + savedUser.setAge("25"); + User updatedUser = userRepository.save(savedUser); + entityManager.flush(); + + // Assert + assertThat(updatedUser.getId()).isEqualTo(savedUser.getId()); + assertThat(updatedUser.getName()).isEqualTo("Jane Doe"); + assertThat(updatedUser.getAge()).isEqualTo("25"); + } + + @Test + @DisplayName("Should delete user by ID") + void testDeleteById() { + // Arrange + User savedUser = userRepository.save(testUser); + entityManager.flush(); + Long userId = savedUser.getId(); + + // Act + userRepository.deleteById(userId); + entityManager.flush(); + + // Assert + Optional deletedUser = userRepository.findById(userId); + assertThat(deletedUser).isEmpty(); + } + + @Test + @DisplayName("Should delete user entity") + void testDelete() { + // Arrange + User savedUser = userRepository.save(testUser); + entityManager.flush(); + + // Act + userRepository.delete(savedUser); + entityManager.flush(); + + // Assert + Optional deletedUser = userRepository.findById(savedUser.getId()); + assertThat(deletedUser).isEmpty(); + } + + @Test + @DisplayName("Should check if user exists by ID") + void testExistsById() { + // Arrange + User savedUser = userRepository.save(testUser); + entityManager.flush(); + + // Act & Assert + assertThat(userRepository.existsById(savedUser.getId())).isTrue(); + assertThat(userRepository.existsById(9999L)).isFalse(); + } + + @Test + @DisplayName("Should count total users in database") + void testCount() { + // Arrange + userRepository.save(testUser); + User user2 = new User(); + user2.setUsername("anotheruser" + testCounter); + user2.setPassword("pass123"); + userRepository.save(user2); + entityManager.flush(); + + // Act + long count = userRepository.count(); + + // Assert + assertThat(count).isGreaterThanOrEqualTo(2); + } + + // ==================== Edge Cases & Validation ==================== + + @Test + @DisplayName("Should handle null social links list") + void testSave_NullSocialLinks() { + // Arrange + testUser.setSocialLinks(null); + + // Act + User savedUser = userRepository.save(testUser); + entityManager.flush(); + + // Assert + assertThat(savedUser.getId()).isNotNull(); + assertThat(savedUser.getUsername()).isEqualTo(testUser.getUsername()); + } + + @Test + @DisplayName("Should persist user with empty social links") + void testSave_EmptySocialLinks() { + // Arrange + testUser.setSocialLinks(List.of()); + + // Act + User savedUser = userRepository.save(testUser); + entityManager.flush(); + entityManager.clear(); + + // Retrieve to verify + Optional retrievedUser = userRepository.findById(savedUser.getId()); + + // Assert + assertThat(retrievedUser) + .isPresent() + .hasValueSatisfying(user -> { + assertThat(user.getSocialLinks()).isEmpty(); + }); + } + + @Test + @DisplayName("Should update user social links") + void testUpdate_SocialLinks() { + // Arrange + User savedUser = userRepository.save(testUser); + entityManager.flush(); + + // Act - Add a new link + SocialLink newLink = new SocialLink(); + newLink.setTitle("GitHub"); + newLink.setUrl("https://github.com/johndoe"); + savedUser.getSocialLinks().add(newLink); + userRepository.save(savedUser); + entityManager.flush(); + entityManager.clear(); + + // Retrieve to verify + Optional retrievedUser = userRepository.findById(savedUser.getId()); + + // Assert + assertThat(retrievedUser) + .isPresent() + .hasValueSatisfying(user -> { + assertThat(user.getSocialLinks()).hasSize(3); + assertThat(user.getSocialLinks()) + .extracting(SocialLink::getTitle) + .containsExactly("Portfolio", "Twitter", "GitHub"); + }); + } +} \ No newline at end of file diff --git a/src/test/java/com/basecamp/HyprLink/security/CustomUserDetailServiceTest.java b/src/test/java/com/basecamp/HyprLink/security/CustomUserDetailServiceTest.java new file mode 100644 index 0000000..0e26e11 --- /dev/null +++ b/src/test/java/com/basecamp/HyprLink/security/CustomUserDetailServiceTest.java @@ -0,0 +1,68 @@ +package com.basecamp.HyprLink.security; + +import com.basecamp.HyprLink.entity.User; +import com.basecamp.HyprLink.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +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.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CustomUserDetailService Tests") +class CustomUserDetailServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private CustomUserDetailService customUserDetailService; + + // ==================== User Details Lookup Tests ==================== + + @Test + @DisplayName("Should return Spring Security user details when username exists") + void testLoadUserByUsername_UserExists_ReturnsUserDetails() { + // Arrange + User entityUser = new User(); + entityUser.setUsername("johndoe"); + entityUser.setPassword("encoded-password"); + + when(userRepository.findByUsername("johndoe")).thenReturn(Optional.of(entityUser)); + + // Act + UserDetails userDetails = customUserDetailService.loadUserByUsername("johndoe"); + + // Assert + assertThat(userDetails.getUsername()).isEqualTo("johndoe"); + assertThat(userDetails.getPassword()).isEqualTo("encoded-password"); + assertThat(userDetails.getAuthorities()) + .extracting("authority") + .containsExactly("ROLE_USER"); + verify(userRepository).findByUsername("johndoe"); + } + + @Test + @DisplayName("Should throw UsernameNotFoundException when username does not exist") + void testLoadUserByUsername_UserMissing_ThrowsUsernameNotFoundException() { + // Arrange + when(userRepository.findByUsername("missing-user")).thenReturn(Optional.empty()); + + // Act + Assert + assertThatThrownBy(() -> customUserDetailService.loadUserByUsername("missing-user")) + .isInstanceOf(UsernameNotFoundException.class) + .hasMessage("User not found"); + verify(userRepository).findByUsername("missing-user"); + } +} + diff --git a/src/test/java/com/basecamp/HyprLink/security/SecurityConfigTest.java b/src/test/java/com/basecamp/HyprLink/security/SecurityConfigTest.java new file mode 100644 index 0000000..e767c7c --- /dev/null +++ b/src/test/java/com/basecamp/HyprLink/security/SecurityConfigTest.java @@ -0,0 +1,95 @@ +package com.basecamp.HyprLink.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.SecurityFilterChain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("SecurityConfig Tests") +class SecurityConfigTest { + + private final SecurityConfig securityConfig = new SecurityConfig(); + + // ==================== Bean Configuration Tests ==================== + + @Test + @DisplayName("Should expose BCrypt password encoder bean") + void testPasswordEncoder_ReturnsBCryptEncoder() { + // Act + PasswordEncoder passwordEncoder = securityConfig.passwordEncoder(); + + // Assert + assertThat(passwordEncoder).isInstanceOf(BCryptPasswordEncoder.class); + } + + @Test + @DisplayName("Should encode and verify password using configured encoder") + void testPasswordEncoder_EncodesAndMatchesRawPassword() { + // Arrange + PasswordEncoder passwordEncoder = securityConfig.passwordEncoder(); + String rawPassword = "my-password"; + + // Act + String encodedPassword = passwordEncoder.encode(rawPassword); + + // Assert + assertThat(encodedPassword).isNotEqualTo(rawPassword); + assertThat(passwordEncoder.matches(rawPassword, encodedPassword)).isTrue(); + } + + // ==================== Filter Chain Configuration Tests ==================== + + @Test + @DisplayName("Should build and return security filter chain") + void testSecurityFilterChain_BuildsAndReturnsFilterChain() throws Exception { + // Arrange + HttpSecurity httpSecurity = mock(HttpSecurity.class); + DefaultSecurityFilterChain filterChain = mock(DefaultSecurityFilterChain.class); + + when(httpSecurity.authorizeHttpRequests(any())).thenReturn(httpSecurity); + when(httpSecurity.formLogin(any())).thenReturn(httpSecurity); + when(httpSecurity.logout(any())).thenReturn(httpSecurity); + when(httpSecurity.build()).thenReturn(filterChain); + + // Act + SecurityFilterChain result = securityConfig.securityFilterChain(httpSecurity); + + // Assert + assertThat(result).isSameAs(filterChain); + } + + @Test + @DisplayName("Should apply authorize, form login, and logout configuration blocks") + void testSecurityFilterChain_AppliesExpectedHttpSecurityBlocks() throws Exception { + // Arrange + HttpSecurity httpSecurity = mock(HttpSecurity.class); + DefaultSecurityFilterChain filterChain = mock(DefaultSecurityFilterChain.class); + + when(httpSecurity.authorizeHttpRequests(any())).thenReturn(httpSecurity); + when(httpSecurity.formLogin(any())).thenReturn(httpSecurity); + when(httpSecurity.logout(any())).thenReturn(httpSecurity); + when(httpSecurity.build()).thenReturn(filterChain); + + // Act + securityConfig.securityFilterChain(httpSecurity); + + // Assert + verify(httpSecurity, times(1)).authorizeHttpRequests(any()); + verify(httpSecurity, times(1)).formLogin(any()); + verify(httpSecurity, times(1)).logout(any()); + verify(httpSecurity, times(1)).build(); + } +} + + + diff --git a/src/test/java/com/basecamp/HyprLink/security/TEST_DOCUMENTATION.md b/src/test/java/com/basecamp/HyprLink/security/TEST_DOCUMENTATION.md new file mode 100644 index 0000000..9c7378c --- /dev/null +++ b/src/test/java/com/basecamp/HyprLink/security/TEST_DOCUMENTATION.md @@ -0,0 +1,126 @@ +# Security Package Test Suite Documentation + +## Overview +The security-layer test suite validates behavior in `CustomUserDetailService` and `SecurityConfig`. + +This suite includes **6 tests** that cover: +- user-details lookup and authority mapping +- missing-user exception handling +- password encoder bean behavior +- security filter-chain builder flow +- application of core security configuration blocks + +## Test Configuration +- **Framework:** JUnit 5 (Jupiter) +- **Testing Library:** AssertJ (Fluent Assertions) +- **Mocking:** Mockito (`@ExtendWith(MockitoExtension.class)`) +- **Security Config Testing:** Unit tests with mocked `HttpSecurity` and `SecurityFilterChain` +- **Style:** Arrange-Act-Assert (AAA) + +## Test Organization + +### CustomUserDetailService Tests (2 tests) + +#### 1. testLoadUserByUsername_UserExists_ReturnsUserDetails +- **Purpose:** Verify user lookup and conversion to Spring Security `UserDetails` +- **Scenario:** Repository returns an existing user by username +- **Assertions:** + - Username and encoded password are mapped correctly + - Authority list contains exactly `ROLE_USER` + - Repository lookup is invoked + +#### 2. testLoadUserByUsername_UserMissing_ThrowsUsernameNotFoundException +- **Purpose:** Verify missing-user error path +- **Scenario:** Repository returns empty for username +- **Assertions:** + - `UsernameNotFoundException` is thrown + - Exception message equals `User not found` + - Repository lookup is invoked + +### SecurityConfig Tests (4 tests) + +#### 3. testPasswordEncoder_ReturnsBCryptEncoder +- **Purpose:** Verify password encoder bean type +- **Scenario:** Request password encoder from config class +- **Assertions:** + - Bean is `BCryptPasswordEncoder` + +#### 4. testPasswordEncoder_EncodesAndMatchesRawPassword +- **Purpose:** Verify encoder behavior +- **Scenario:** Encode a raw password and validate with `matches` +- **Assertions:** + - Encoded value differs from raw password + - `matches` returns true for raw/encoded pair + +#### 5. testSecurityFilterChain_BuildsAndReturnsFilterChain +- **Purpose:** Verify filter-chain construction path +- **Scenario:** Build security filter chain using mocked `HttpSecurity` +- **Assertions:** + - Returned chain matches the result of `http.build()` + +#### 6. testSecurityFilterChain_AppliesExpectedHttpSecurityBlocks +- **Purpose:** Verify all expected security config blocks are applied +- **Scenario:** Run `securityFilterChain` with mocked fluent `HttpSecurity` +- **Assertions:** + - `authorizeHttpRequests` called once + - `formLogin` called once + - `logout` called once + - `build` called once + +## Key Testing Strategies + +### 1. Layered Unit Testing +- `CustomUserDetailService` tests isolate repository behavior with Mockito +- `SecurityConfig` tests isolate HttpSecurity builder interactions with Mockito + +### 2. Behavior-Focused Assertions +- Validate returned domain/security objects and mapped authorities +- Validate security builder interactions and final chain return + +### 3. Delegation Verification +- Confirm repository lookup calls in user-details service +- Confirm core security configuration calls in config class + +### 4. Consistent Style +- `@DisplayName` on all tests +- Section banners for readability +- Method names follow `testMethod_Scenario_Expected` + +## Running the Tests + +### Run all security package tests: +```bash +./mvnw test -Dtest=SecurityConfigTest,CustomUserDetailServiceTest +``` + +### Run one test class: +```bash +./mvnw test -Dtest=SecurityConfigTest +``` + +### Run one test method: +```bash +./mvnw test -Dtest=CustomUserDetailServiceTest#testLoadUserByUsername_UserMissing_ThrowsUsernameNotFoundException +``` + +## Test Results + +✅ Security test suite passes successfully +- **Tests run:** 6 +- **Failures:** 0 +- **Errors:** 0 +- **Skipped:** 0 + +## Coverage Summary + +The suite covers: +- ✅ Custom user-details lookup success and failure paths +- ✅ Authority mapping to `ROLE_USER` +- ✅ BCrypt password encoder type and behavior +- ✅ Security filter-chain construction +- ✅ Core security configuration block application + +## Future Enhancements +- Add endpoint-level authorization tests if web test auto-configuration support is added +- Add login-failure parameter handling tests +- Add role-based authorization tests if additional roles are introduced diff --git a/src/test/java/com/basecamp/HyprLink/service/AuthServiceTest.java b/src/test/java/com/basecamp/HyprLink/service/AuthServiceTest.java new file mode 100644 index 0000000..18fde14 --- /dev/null +++ b/src/test/java/com/basecamp/HyprLink/service/AuthServiceTest.java @@ -0,0 +1,122 @@ +package com.basecamp.HyprLink.service; + +import com.basecamp.HyprLink.entity.User; +import com.basecamp.HyprLink.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AuthService Tests") +class AuthServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private AuthService authService; + + private User testUser; + + @BeforeEach + void setUp() { + testUser = new User(); + testUser.setUsername("johndoe"); + testUser.setPassword("plain-password"); + testUser.setName("John Doe"); + } + + // ==================== Registration Tests ==================== + + @Test + @DisplayName("Should encode password and save user when registering") + void testRegisterUser_EncodesPasswordAndSaves() { + // Arrange + when(passwordEncoder.encode("plain-password")).thenReturn("encoded-password"); + when(userRepository.save(testUser)).thenReturn(testUser); + + // Act + authService.registerUser(testUser); + + // Assert + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(userCaptor.capture()); + + User capturedUser = userCaptor.getValue(); + assertThat(capturedUser.getPassword()).isEqualTo("encoded-password"); + assertThat(capturedUser.getUsername()).isEqualTo("johndoe"); + } + + @Test + @DisplayName("Should return saved user when registration succeeds") + void testRegisterUser_ReturnsSavedUser() { + // Arrange + when(passwordEncoder.encode("plain-password")).thenReturn("encoded-password"); + when(userRepository.save(testUser)).thenReturn(testUser); + + // Act + User savedUser = authService.registerUser(testUser); + + // Assert + assertThat(savedUser).isSameAs(testUser); + verify(passwordEncoder).encode("plain-password"); + verify(userRepository).save(testUser); + } + + // ==================== Registration Form Initialization Tests ==================== + + @Test + @DisplayName("Should prepare registration data with one blank social link") + void testPrepareRegistrationFormData_InitializesBlankLink() { + // Act + User preparedUser = authService.prepareRegistrationFormData(); + + // Assert + assertThat(preparedUser).isNotNull(); + assertThat(preparedUser.getSocialLinks()).isNotNull(); + assertThat(preparedUser.getSocialLinks()).hasSize(1); + assertThat(preparedUser.getSocialLinks().get(0).getTitle()).isNull(); + assertThat(preparedUser.getSocialLinks().get(0).getUrl()).isNull(); + } + + @Test + @DisplayName("Should return a fresh user model each time registration data is prepared") + void testPrepareRegistrationFormData_ReturnsFreshInstancePerCall() { + // Act + User first = authService.prepareRegistrationFormData(); + User second = authService.prepareRegistrationFormData(); + + // Assert + assertThat(first).isNotSameAs(second); + assertThat(first.getSocialLinks()).isNotSameAs(second.getSocialLinks()); + assertThat(first.getSocialLinks()).hasSize(1); + assertThat(second.getSocialLinks()).hasSize(1); + } + + // ==================== Theme Tests ==================== + + @Test + @DisplayName("Should return available themes") + void testGetAvailableThemes_ReturnsDefaultTheme() { + // Act + var themes = authService.getAvailableThemes(); + + // Assert + assertThat(themes).containsExactly("default"); + } +} + + diff --git a/src/test/java/com/basecamp/HyprLink/service/DashboardServiceTest.java b/src/test/java/com/basecamp/HyprLink/service/DashboardServiceTest.java new file mode 100644 index 0000000..4002666 --- /dev/null +++ b/src/test/java/com/basecamp/HyprLink/service/DashboardServiceTest.java @@ -0,0 +1,226 @@ +package com.basecamp.HyprLink.service; + +import com.basecamp.HyprLink.entity.SocialLink; +import com.basecamp.HyprLink.entity.User; +import com.basecamp.HyprLink.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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 java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("DashboardService Tests") +class DashboardServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private DashboardService dashboardService; + + private User existingUser; + + @BeforeEach + void setUp() { + existingUser = new User(); + existingUser.setUsername("johndoe"); + existingUser.setName("Old Name"); + existingUser.setAge("30"); + existingUser.setPronouns("he/him"); + existingUser.setBio("Old bio"); + existingUser.setProfilePicture("old-pic"); + existingUser.setTheme("default"); + + SocialLink existingLink = new SocialLink(); + existingLink.setTitle("Portfolio"); + existingLink.setUrl("https://example.com/portfolio"); + existingUser.setSocialLinks(new ArrayList<>(List.of(existingLink))); + } + + // ==================== Dashboard Retrieval Tests ==================== + + @Test + @DisplayName("Should return user and append a blank social link for dashboard") + void testGetUserForDashboard_UserExists_AppendsBlankLink() { + // Arrange + when(userRepository.findByUsername("johndoe")).thenReturn(Optional.of(existingUser)); + + // Act + User result = dashboardService.getUserForDashboard("johndoe"); + + // Assert + assertThat(result).isSameAs(existingUser); + assertThat(result.getSocialLinks()).hasSize(2); + SocialLink appendedLink = result.getSocialLinks().get(1); + assertThat(appendedLink.getTitle()).isNull(); + assertThat(appendedLink.getUrl()).isNull(); + } + + @Test + @DisplayName("Should return null when dashboard user is not found") + void testGetUserForDashboard_UserNotFound_ReturnsNull() { + // Arrange + when(userRepository.findByUsername("missing-user")).thenReturn(Optional.empty()); + + // Act + User result = dashboardService.getUserForDashboard("missing-user"); + + // Assert + assertThat(result).isNull(); + } + + // ==================== Profile Update Tests ==================== + + @Test + @DisplayName("Should update profile fields, filter blank links, and save user") + void testUpdateUserProfile_UpdatesFieldsFiltersLinksAndSaves() { + // Arrange + User updatedData = new User(); + updatedData.setName("New Name"); + updatedData.setAge("25"); + updatedData.setPronouns("they/them"); + updatedData.setBio("New bio"); + updatedData.setProfilePicture("new-pic"); + updatedData.setTheme("default"); + + SocialLink validLink = new SocialLink(); + validLink.setTitle("GitHub"); + validLink.setUrl("https://github.com/johndoe"); + + SocialLink blankTitle = new SocialLink(); + blankTitle.setTitle(" "); + blankTitle.setUrl("https://example.com"); + + SocialLink blankUrl = new SocialLink(); + blankUrl.setTitle("LinkedIn"); + blankUrl.setUrl(" "); + + SocialLink nullFields = new SocialLink(); + nullFields.setTitle(null); + nullFields.setUrl(null); + + updatedData.setSocialLinks(List.of(validLink, blankTitle, blankUrl, nullFields)); + + when(userRepository.findByUsername("johndoe")).thenReturn(Optional.of(existingUser)); + when(userRepository.save(existingUser)).thenReturn(existingUser); + + // Act + User result = dashboardService.updateUserProfile(updatedData, "johndoe"); + + // Assert + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(userCaptor.capture()); + + User savedUser = userCaptor.getValue(); + assertThat(result).isSameAs(existingUser); + assertThat(savedUser.getName()).isEqualTo("New Name"); + assertThat(savedUser.getAge()).isEqualTo("25"); + assertThat(savedUser.getPronouns()).isEqualTo("they/them"); + assertThat(savedUser.getBio()).isEqualTo("New bio"); + assertThat(savedUser.getProfilePicture()).isEqualTo("new-pic"); + assertThat(savedUser.getTheme()).isEqualTo("default"); + assertThat(savedUser.getSocialLinks()).hasSize(1); + assertThat(savedUser.getSocialLinks().get(0).getTitle()).isEqualTo("GitHub"); + assertThat(savedUser.getSocialLinks().get(0).getUrl()).isEqualTo("https://github.com/johndoe"); + } + + @Test + @DisplayName("Should keep existing links when updated data has null social links") + void testUpdateUserProfile_NullSocialLinks_KeepsExistingLinks() { + // Arrange + User updatedData = new User(); + updatedData.setName("New Name"); + updatedData.setAge("27"); + updatedData.setPronouns("they/them"); + updatedData.setBio("Updated bio"); + updatedData.setProfilePicture("new-avatar"); + updatedData.setTheme("default"); + updatedData.setSocialLinks(null); + + List originalLinks = new ArrayList<>(existingUser.getSocialLinks()); + + when(userRepository.findByUsername("johndoe")).thenReturn(Optional.of(existingUser)); + when(userRepository.save(existingUser)).thenReturn(existingUser); + + // Act + dashboardService.updateUserProfile(updatedData, "johndoe"); + + // Assert + assertThat(existingUser.getName()).isEqualTo("New Name"); + assertThat(existingUser.getAge()).isEqualTo("27"); + assertThat(existingUser.getPronouns()).isEqualTo("they/them"); + assertThat(existingUser.getBio()).isEqualTo("Updated bio"); + assertThat(existingUser.getProfilePicture()).isEqualTo("new-avatar"); + assertThat(existingUser.getSocialLinks()).hasSameSizeAs(originalLinks); + assertThat(existingUser.getSocialLinks().get(0).getTitle()).isEqualTo("Portfolio"); + verify(userRepository).save(existingUser); + } + + @Test + @DisplayName("Should clear existing links when updated data contains only blank links") + void testUpdateUserProfile_OnlyBlankLinks_ClearsExistingLinks() { + // Arrange + User updatedData = new User(); + + SocialLink blankOne = new SocialLink(); + blankOne.setTitle(" "); + blankOne.setUrl(" "); + + SocialLink blankTwo = new SocialLink(); + blankTwo.setTitle(""); + blankTwo.setUrl(""); + + updatedData.setSocialLinks(List.of(blankOne, blankTwo)); + + when(userRepository.findByUsername("johndoe")).thenReturn(Optional.of(existingUser)); + when(userRepository.save(existingUser)).thenReturn(existingUser); + + // Act + User result = dashboardService.updateUserProfile(updatedData, "johndoe"); + + // Assert + assertThat(result).isSameAs(existingUser); + assertThat(existingUser.getSocialLinks()).isEmpty(); + verify(userRepository).save(existingUser); + } + + @Test + @DisplayName("Should throw exception when updating profile for missing user") + void testUpdateUserProfile_UserNotFound_ThrowsException() { + // Arrange + User updatedData = new User(); + when(userRepository.findByUsername("missing-user")).thenReturn(Optional.empty()); + + // Act + Assert + assertThatThrownBy(() -> dashboardService.updateUserProfile(updatedData, "missing-user")) + .isInstanceOf(RuntimeException.class) + .hasMessage("User not found: missing-user"); + } + + // ==================== Theme Tests ==================== + + @Test + @DisplayName("Should return available themes") + void testGetAvailableThemes_ReturnsDefaultTheme() { + // Act + List themes = dashboardService.getAvailableThemes(); + + // Assert + assertThat(themes).containsExactly("default"); + } +} + + diff --git a/src/test/java/com/basecamp/HyprLink/service/ProfileServiceTest.java b/src/test/java/com/basecamp/HyprLink/service/ProfileServiceTest.java new file mode 100644 index 0000000..a50bf0d --- /dev/null +++ b/src/test/java/com/basecamp/HyprLink/service/ProfileServiceTest.java @@ -0,0 +1,80 @@ +package com.basecamp.HyprLink.service; + +import com.basecamp.HyprLink.entity.User; +import com.basecamp.HyprLink.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +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 java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ProfileService Tests") +class ProfileServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private ProfileService profileService; + + // ==================== Profile Lookup Tests ==================== + + @Test + @DisplayName("Should return user profile when user ID exists") + void testGetUserProfileById_UserExists_ReturnsUser() { + // Arrange + User user = new User(); + user.setId(1L); + user.setUsername("johndoe"); + user.setName("John Doe"); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + // Act + User result = profileService.getUserProfileById(1L); + + // Assert + assertThat(result).isSameAs(user); + assertThat(result.getId()).isEqualTo(1L); + assertThat(result.getUsername()).isEqualTo("johndoe"); + verify(userRepository).findById(1L); + } + + @Test + @DisplayName("Should return null when user ID does not exist") + void testGetUserProfileById_UserMissing_ReturnsNull() { + // Arrange + when(userRepository.findById(999L)).thenReturn(Optional.empty()); + + // Act + User result = profileService.getUserProfileById(999L); + + // Assert + assertThat(result).isNull(); + verify(userRepository).findById(999L); + } + + @Test + @DisplayName("Should return null when repository has no user for null ID") + void testGetUserProfileById_NullId_ReturnsNull() { + // Arrange + when(userRepository.findById(null)).thenReturn(Optional.empty()); + + // Act + User result = profileService.getUserProfileById(null); + + // Assert + assertThat(result).isNull(); + verify(userRepository).findById(null); + } +} + + diff --git a/src/test/java/com/basecamp/HyprLink/service/TEST_DOCUMENTATION.md b/src/test/java/com/basecamp/HyprLink/service/TEST_DOCUMENTATION.md new file mode 100644 index 0000000..541dacc --- /dev/null +++ b/src/test/java/com/basecamp/HyprLink/service/TEST_DOCUMENTATION.md @@ -0,0 +1,192 @@ +# Service Layer Test Suite Documentation + +## Overview +The service-layer test suite verifies business logic in `AuthService`, `DashboardService`, and `ProfileService` with isolated unit tests using Mockito. + +This suite includes **15 test cases** that cover: +- successful service behavior +- repository interaction expectations +- filtering and transformation logic +- edge cases and error paths + +## Test Configuration +- **Framework:** JUnit 5 (Jupiter) +- **Testing Library:** AssertJ (Fluent Assertions) +- **Mocking:** Mockito (`@ExtendWith(MockitoExtension.class)`) +- **Style:** Arrange-Act-Assert (AAA) + +## Test Organization + +### AuthService Tests (5 tests) + +#### 1. testRegisterUser_EncodesPasswordAndSaves +- **Purpose:** Verify that registration hashes the password before persistence +- **Scenario:** Register a user with plain text password +- **Assertions:** + - Saved user has encoded password + - Username remains unchanged + +#### 2. testRegisterUser_ReturnsSavedUser +- **Purpose:** Verify service returns repository save result +- **Scenario:** Register valid user +- **Assertions:** + - Returned instance matches repository response + - Encoder and repository are called correctly + +#### 3. testPrepareRegistrationFormData_InitializesBlankLink +- **Purpose:** Verify register form model starts with one empty social link row +- **Scenario:** Build registration form data +- **Assertions:** + - User object exists + - Social links list exists and contains one blank link + +#### 4. testPrepareRegistrationFormData_ReturnsFreshInstancePerCall +- **Purpose:** Verify each form preparation call is independent +- **Scenario:** Call form preparation twice +- **Assertions:** + - Returned users are different instances + - Social link lists are different instances + - Both calls include one blank link row + +#### 5. testGetAvailableThemes_ReturnsDefaultTheme +- **Purpose:** Verify available themes list +- **Scenario:** Fetch themes for auth pages +- **Assertions:** + - Exactly `"default"` is returned + +### DashboardService Tests (7 tests) + +#### 6. testGetUserForDashboard_UserExists_AppendsBlankLink +- **Purpose:** Verify dashboard always includes an additional empty link slot +- **Scenario:** Existing user is loaded +- **Assertions:** + - User is returned + - Social link count increases by one + - Appended link is blank + +#### 7. testGetUserForDashboard_UserNotFound_ReturnsNull +- **Purpose:** Verify graceful missing-user behavior +- **Scenario:** Username does not exist +- **Assertions:** + - Method returns null + +#### 8. testUpdateUserProfile_UpdatesFieldsFiltersLinksAndSaves +- **Purpose:** Verify full profile update and social link filtering +- **Scenario:** Updated payload includes valid + invalid links +- **Assertions:** + - Core profile fields are updated + - Blank/null links are filtered out + - Repository save is called with filtered result + +#### 9. testUpdateUserProfile_NullSocialLinks_KeepsExistingLinks +- **Purpose:** Verify null social-links input does not erase existing links +- **Scenario:** Updated payload has `socialLinks = null` +- **Assertions:** + - Basic fields are updated + - Existing links remain intact + - Save is still called + +#### 10. testUpdateUserProfile_OnlyBlankLinks_ClearsExistingLinks +- **Purpose:** Verify all-invalid social links clear persisted links +- **Scenario:** Updated payload contains only blank link entries +- **Assertions:** + - Existing links are cleared + - Save is called with empty link list + +#### 11. testUpdateUserProfile_UserNotFound_ThrowsException +- **Purpose:** Verify explicit failure for missing update target user +- **Scenario:** Username not found in repository +- **Assertions:** + - RuntimeException is thrown + - Exception message matches expected text + +#### 12. testGetAvailableThemes_ReturnsDefaultTheme +- **Purpose:** Verify dashboard theme options +- **Scenario:** Fetch themes +- **Assertions:** + - Exactly `"default"` is returned + +### ProfileService Tests (3 tests) + +#### 13. testGetUserProfileById_UserExists_ReturnsUser +- **Purpose:** Verify profile lookup by valid ID +- **Scenario:** Repository returns a matching user +- **Assertions:** + - Returned object matches repository object + - Key fields (id/username) are correct + +#### 14. testGetUserProfileById_UserMissing_ReturnsNull +- **Purpose:** Verify profile lookup behavior when ID is missing +- **Scenario:** Repository returns empty +- **Assertions:** + - Method returns null + +#### 15. testGetUserProfileById_NullId_ReturnsNull +- **Purpose:** Verify behavior for null ID input +- **Scenario:** Repository returns empty for null ID +- **Assertions:** + - Method returns null + - Repository called with null + +## Key Testing Strategies + +### 1. Service Isolation +- Repositories and encoders are mocked +- Tests validate service logic, not database behavior + +### 2. Interaction + State Validation +- Verify both **what changed** (entity fields/lists) and **what was called** (`save`, `findById`, `findByUsername`) + +### 3. Input Matrix Coverage +- Valid input +- Missing user input +- Null collections +- Blank/invalid social links +- Null ID path + +### 4. Consistent Style +- `@DisplayName` used across all tests +- Method names follow `testMethod_Scenario_Expected` +- Sections grouped with comment banners to match repository test readability + +## Running the Tests + +### Run all service tests +```bash +./mvnw test -Dtest=AuthServiceTest,DashboardServiceTest,ProfileServiceTest +``` + +### Run one class +```bash +./mvnw test -Dtest=DashboardServiceTest +``` + +### Run one test method +```bash +./mvnw test -Dtest=DashboardServiceTest#testUpdateUserProfile_UpdatesFieldsFiltersLinksAndSaves +``` + +## Test Results + +✅ Service test suite passes successfully +- **Tests run:** 15 +- **Failures:** 0 +- **Errors:** 0 +- **Skipped:** 0 + +## Coverage Summary + +The suite covers: +- ✅ Auth registration password encoding workflow +- ✅ Registration form model initialization logic +- ✅ Dashboard model preparation logic +- ✅ Profile update mapping behavior +- ✅ Social link filtering rules +- ✅ Missing-user exception path +- ✅ Profile retrieval success and null-return paths + +## Future Enhancements +- Add negative tests for malformed profile field input (if validation rules are introduced) +- Add parameterized tests for social link filtering combinations +- Add tests for additional theme options if themes become dynamic +