From 5d2c4d62acdd79f52f3f879a1756bad24a10ce8c Mon Sep 17 00:00:00 2001 From: Sean Starkey Date: Mon, 1 Jun 2026 17:51:16 -0600 Subject: [PATCH] Test cleanup --- .../notesvault/auth/AuthApiTestSupport.java | 49 +++++++ .../notesvault/auth/AuthControllerTest.java | 120 ------------------ .../auth/AuthRegistrationControllerTest.java | 91 +++++++++++++ .../controller/ControllerApiTestSupport.java | 104 +++++++++++++++ ...t.java => NoteCreationControllerTest.java} | 91 ++++--------- 5 files changed, 266 insertions(+), 189 deletions(-) create mode 100644 src/test/java/com/seanstarkey/notesvault/auth/AuthApiTestSupport.java delete mode 100644 src/test/java/com/seanstarkey/notesvault/auth/AuthControllerTest.java create mode 100644 src/test/java/com/seanstarkey/notesvault/auth/AuthRegistrationControllerTest.java create mode 100644 src/test/java/com/seanstarkey/notesvault/controller/ControllerApiTestSupport.java rename src/test/java/com/seanstarkey/notesvault/controller/{NoteControllerTest.java => NoteCreationControllerTest.java} (55%) diff --git a/src/test/java/com/seanstarkey/notesvault/auth/AuthApiTestSupport.java b/src/test/java/com/seanstarkey/notesvault/auth/AuthApiTestSupport.java new file mode 100644 index 0000000..9439a58 --- /dev/null +++ b/src/test/java/com/seanstarkey/notesvault/auth/AuthApiTestSupport.java @@ -0,0 +1,49 @@ +/** + * AuthApiTestSupport.java + * + * Shared MockMvc helpers for authentication API integration tests. + */ +package com.seanstarkey.notesvault.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.seanstarkey.notesvault.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +/** + * Provides common fixtures and request helpers used by focused auth controller + * test classes. + */ +abstract class AuthApiTestSupport { + + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected UserRepository userRepository; + + @Autowired + protected PasswordEncoder passwordEncoder; + + @Autowired + protected JwtUtil jwtUtil; + + @Autowired + protected ObjectMapper objectMapper; + + protected ResultActions register(String username, String password) throws Exception { + return mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "username": "%s", + "password": "%s" + } + """.formatted(username, password))); + } +} diff --git a/src/test/java/com/seanstarkey/notesvault/auth/AuthControllerTest.java b/src/test/java/com/seanstarkey/notesvault/auth/AuthControllerTest.java deleted file mode 100644 index d94c693..0000000 --- a/src/test/java/com/seanstarkey/notesvault/auth/AuthControllerTest.java +++ /dev/null @@ -1,120 +0,0 @@ -/** - * AuthControllerTest.java - */ -package com.seanstarkey.notesvault.auth; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.seanstarkey.notesvault.repository.UserRepository; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.transaction.annotation.Transactional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@SpringBootTest -@AutoConfigureMockMvc -@Transactional -class AuthControllerTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - private UserRepository userRepository; - - @Autowired - private PasswordEncoder passwordEncoder; - - @Autowired - private JwtUtil jwtUtil; - - @Autowired - private ObjectMapper objectMapper; - - @Test - void registerCreatesUserAndReturnsPublicProfile() throws Exception { - mockMvc.perform(post("/auth/register") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "username": "starkey", - "password": "secret123" - } - """)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.id").isNotEmpty()) - .andExpect(jsonPath("$.username").value("starkey")) - .andExpect(jsonPath("$.createdAt").isNotEmpty()) - .andExpect(jsonPath("$.password").doesNotExist()) - .andExpect(jsonPath("$.passwordHash").doesNotExist()); - - var savedUser = userRepository.findByUsername("starkey").orElseThrow(); - assertThat(savedUser.getPasswordHash()).isNotEqualTo("secret123"); - assertThat(passwordEncoder.matches("secret123", savedUser.getPasswordHash())).isTrue(); - } - - /* - * Test: duplicate usernames are rejected. - * Verifies that the unique account invariant is enforced at the API boundary - * with HTTP 409 instead of silently overwriting or exposing database details. - */ - @Test - void registerRejectsDuplicateUsername() throws Exception { - register("starkey", "secret123").andExpect(status().isCreated()); - - register("starkey", "another123") - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.error").value("Username is already registered")); - } - - /* - * Test: registered users can log in and receive a bearer token. - * Verifies that POST /auth/login authenticates stored BCrypt credentials, - * returns token metadata, and issues a JWT whose subject is the username. - */ - @Test - void loginAuthenticatesUserAndReturnsBearerToken() throws Exception { - register("starkey", "secret123").andExpect(status().isCreated()); - - String responseBody = mockMvc.perform(post("/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "username": "starkey", - "password": "secret123" - } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.token").isNotEmpty()) - .andExpect(jsonPath("$.tokenType").value("Bearer")) - .andExpect(jsonPath("$.expiresInSeconds").value(86400)) - .andExpect(jsonPath("$.expiresAt").isNotEmpty()) - .andReturn() - .getResponse() - .getContentAsString(); - - String token = objectMapper.readTree(responseBody).get("token").asText(); - - assertThat(jwtUtil.getUsernameFromToken(token)).isEqualTo("starkey"); - assertThat(jwtUtil.isTokenValid(token, "starkey")).isTrue(); - } - - private org.springframework.test.web.servlet.ResultActions register(String username, String password) throws Exception { - return mockMvc.perform(post("/auth/register") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "username": "%s", - "password": "%s" - } - """.formatted(username, password))); - } -} diff --git a/src/test/java/com/seanstarkey/notesvault/auth/AuthRegistrationControllerTest.java b/src/test/java/com/seanstarkey/notesvault/auth/AuthRegistrationControllerTest.java new file mode 100644 index 0000000..d68dc3a --- /dev/null +++ b/src/test/java/com/seanstarkey/notesvault/auth/AuthRegistrationControllerTest.java @@ -0,0 +1,91 @@ +/** + * AuthRegistrationControllerTest.java + * + * API-level tests for public user registration behavior. + */ +package com.seanstarkey.notesvault.auth; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Verifies registration behavior through the real Spring MVC, Security, JPA, + * validation, and Flyway-backed H2 application stack. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class AuthRegistrationControllerTest extends AuthApiTestSupport { + + /* + * Test: registering a new user returns public account details. + * Verifies that POST /auth/register creates a user, hashes the password, + * returns HTTP 201, and never exposes credential material in the response. + */ + @Test + void registerCreatesUserAndReturnsPublicProfile() throws Exception { + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "username": "alice", + "password": "secret123" + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.username").value("alice")) + .andExpect(jsonPath("$.createdAt").isNotEmpty()) + .andExpect(jsonPath("$.password").doesNotExist()) + .andExpect(jsonPath("$.passwordHash").doesNotExist()); + + var savedUser = userRepository.findByUsername("alice").orElseThrow(); + assertThat(savedUser.getPasswordHash()).isNotEqualTo("secret123"); + assertThat(passwordEncoder.matches("secret123", savedUser.getPasswordHash())).isTrue(); + } + + /* + * Test: duplicate usernames are rejected. + * Verifies that the unique account invariant is enforced at the API boundary + * with HTTP 409 instead of silently overwriting or exposing database details. + */ + @Test + void registerRejectsDuplicateUsername() throws Exception { + register("alice", "secret123").andExpect(status().isCreated()); + + register("alice", "another123") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error").value("Username is already registered")); + } + + /* + * Test: invalid registration payloads return field-level validation details. + * Verifies that username and password constraints are enforced before + * persistence and reported with the documented HTTP 422 response shape. + */ + @Test + void registerRejectsInvalidPayload() throws Exception { + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "username": "al", + "password": "short" + } + """)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.error").value("Validation failed")) + .andExpect(jsonPath("$.fields.username").exists()) + .andExpect(jsonPath("$.fields.password").exists()); + } +} diff --git a/src/test/java/com/seanstarkey/notesvault/controller/ControllerApiTestSupport.java b/src/test/java/com/seanstarkey/notesvault/controller/ControllerApiTestSupport.java new file mode 100644 index 0000000..eded31d --- /dev/null +++ b/src/test/java/com/seanstarkey/notesvault/controller/ControllerApiTestSupport.java @@ -0,0 +1,104 @@ +/** + * ControllerApiTestSupport.java + * + * Shared MockMvc helpers for note and sharing API integration tests. + */ +package com.seanstarkey.notesvault.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.seanstarkey.notesvault.auth.JwtUtil; +import com.seanstarkey.notesvault.entity.Note; +import com.seanstarkey.notesvault.entity.NoteShare; +import com.seanstarkey.notesvault.entity.User; +import com.seanstarkey.notesvault.repository.NoteRepository; +import com.seanstarkey.notesvault.repository.NoteShareRepository; +import com.seanstarkey.notesvault.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Provides common authenticated request setup for focused note controller test + * classes. + */ +abstract class ControllerApiTestSupport { + + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected JwtUtil jwtUtil; + + @Autowired + protected NoteRepository noteRepository; + + @Autowired + protected NoteShareRepository noteShareRepository; + + @Autowired + protected UserRepository userRepository; + + @Autowired + protected ObjectMapper objectMapper; + + protected void register(String username, String password) throws Exception { + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "username": "%s", + "password": "%s" + } + """.formatted(username, password))) + .andExpect(status().isCreated()); + + assertThat(userRepository.findByUsername(username)).isPresent(); + } + + protected UUID createNote(String token, String title, String content) throws Exception { + String responseBody = mockMvc.perform(post("/notes") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "%s", + "content": "%s" + } + """.formatted(title, content))) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + return UUID.fromString(objectMapper.readTree(responseBody).get("id").asText()); + } + + protected ResultActions shareNote(String token, UUID noteId, String targetUsername) throws Exception { + return mockMvc.perform(post("/notes/{id}/share", noteId) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "username": "%s" + } + """.formatted(targetUsername))); + } + + protected void shareWith(UUID noteId, String username) { + Note note = noteRepository.findById(noteId).orElseThrow(); + User sharedWith = userRepository.findByUsername(username).orElseThrow(); + + NoteShare share = new NoteShare(); + share.setNote(note); + share.setSharedWith(sharedWith); + noteShareRepository.saveAndFlush(share); + } +} diff --git a/src/test/java/com/seanstarkey/notesvault/controller/NoteControllerTest.java b/src/test/java/com/seanstarkey/notesvault/controller/NoteCreationControllerTest.java similarity index 55% rename from src/test/java/com/seanstarkey/notesvault/controller/NoteControllerTest.java rename to src/test/java/com/seanstarkey/notesvault/controller/NoteCreationControllerTest.java index 18ea09e..6a9b885 100644 --- a/src/test/java/com/seanstarkey/notesvault/controller/NoteControllerTest.java +++ b/src/test/java/com/seanstarkey/notesvault/controller/NoteCreationControllerTest.java @@ -1,65 +1,34 @@ /** - * NoteControllerTest.java + * NoteCreationControllerTest.java * - * API-level tests for authenticated note endpoints exposed by NoteController. + * API-level tests for authenticated note creation behavior. */ package com.seanstarkey.notesvault.controller; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.seanstarkey.notesvault.auth.JwtUtil; import com.seanstarkey.notesvault.entity.Note; -import com.seanstarkey.notesvault.entity.NoteShare; -import com.seanstarkey.notesvault.entity.User; -import com.seanstarkey.notesvault.repository.NoteRepository; -import com.seanstarkey.notesvault.repository.NoteShareRepository; -import com.seanstarkey.notesvault.repository.UserRepository; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; import java.util.Map; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** - * Verifies note CRUD, sharing, authentication, authorization, and validation - * behavior through the real Spring MVC, Security, JPA, validation, and - * Flyway-backed H2 application stack. + * Verifies POST /notes through the real Spring MVC, Security, JPA, validation, + * and Flyway-backed H2 application stack. */ @SpringBootTest @AutoConfigureMockMvc @Transactional -class NoteControllerTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - private JwtUtil jwtUtil; - - @Autowired - private NoteRepository noteRepository; - - @Autowired - private NoteShareRepository noteShareRepository; - - @Autowired - private UserRepository userRepository; - - @Autowired - private ObjectMapper objectMapper; +class NoteCreationControllerTest extends ControllerApiTestSupport { /* * Test: authenticated users can create notes. @@ -119,45 +88,29 @@ class NoteControllerTest { .andExpect(jsonPath("$.error").value("Unauthorized")); } - private void register(String username, String password) throws Exception { - mockMvc.perform(post("/auth/register") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "username": "%s", - "password": "%s" - } - """.formatted(username, password))) - .andExpect(status().isCreated()); + /* + * Test: invalid note creation payloads return field-level validation details. + * Verifies that POST /notes rejects blank content before persistence and + * reports the documented HTTP 422 response shape. + */ + @Test + void createNoteRejectsInvalidPayload() throws Exception { + register("alice", "secret123"); + String token = jwtUtil.generateToken("alice"); - assertThat(userRepository.findByUsername(username)).isPresent(); - } - - private UUID createNote(String token, String title, String content) throws Exception { - String responseBody = mockMvc.perform(post("/notes") + mockMvc.perform(post("/notes") .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) .contentType(MediaType.APPLICATION_JSON) .content(""" { - "title": "%s", - "content": "%s" + "title": "My Note", + "content": "" } - """.formatted(title, content))) - .andExpect(status().isCreated()) - .andReturn() - .getResponse() - .getContentAsString(); + """)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.error").value("Validation failed")) + .andExpect(jsonPath("$.fields.content").exists()); - return UUID.fromString(objectMapper.readTree(responseBody).get("id").asText()); - } - - private void shareWith(UUID noteId, String username) { - Note note = noteRepository.findById(noteId).orElseThrow(); - User sharedWith = userRepository.findByUsername(username).orElseThrow(); - - NoteShare share = new NoteShare(); - share.setNote(note); - share.setSharedWith(sharedWith); - noteShareRepository.saveAndFlush(share); + assertThat(noteRepository.findAll()).isEmpty(); } }