diff --git a/data/mydb.mv.db b/data/mydb.mv.db new file mode 100644 index 00000000..98a63848 Binary files /dev/null and b/data/mydb.mv.db differ diff --git a/data/mydb.trace.db b/data/mydb.trace.db new file mode 100644 index 00000000..54a2bafe --- /dev/null +++ b/data/mydb.trace.db @@ -0,0 +1,73 @@ +2026-04-21 19:51:51.423293+01:00 jdbc[3]: exception +org.h2.jdbc.JdbcSQLNonTransientException: The object is already closed [90007-224] +2026-04-22 05:55:04.822844+01:00 jdbc[3]: exception +org.h2.jdbc.JdbcSQLNonTransientException: The object is already closed [90007-224] +2026-04-22 06:09:33.475810+01:00 jdbc[3]: exception +org.h2.jdbc.JdbcSQLNonTransientException: The object is already closed [90007-224] +2026-04-22 06:16:11.292904+01:00 jdbc[16]: exception +org.h2.jdbc.JdbcSQLNonTransientException: The object is already closed [90007-224] +2026-04-22 12:24:23.617910+01:00 jdbc[3]: exception +org.h2.jdbc.JdbcSQLNonTransientException: The object is already closed [90007-224] +2026-04-22 12:24:30.987650+01:00 jdbc[3]: exception +org.h2.jdbc.JdbcSQLNonTransientException: The object is already closed [90007-224] +2026-04-22 12:24:33.627303+01:00 jdbc[3]: exception +org.h2.jdbc.JdbcSQLNonTransientException: The object is already closed [90007-224] +2026-04-22 16:38:46.730818Z jdbc[3]: exception +java.sql.SQLClientInfoException: Client info name 'ApplicationName' not supported. + at org.h2.jdbc.JdbcConnection.setClientInfo(JdbcConnection.java:1624) + at com.intellij.database.remote.jdbc.impl.RemoteConnectionImpl.setClientInfo(RemoteConnectionImpl.java:468) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360) + at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200) + at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197) + at java.base/java.security.AccessController.doPrivileged(AccessController.java:714) + at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196) + at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721) + at java.base/java.security.AccessController.doPrivileged(AccessController.java:400) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) + at java.base/java.lang.Thread.run(Thread.java:1583) +2026-04-22 16:38:53.145271Z jdbc[3]: exception +java.sql.SQLClientInfoException: Client info name 'ApplicationName' not supported. + at org.h2.jdbc.JdbcConnection.setClientInfo(JdbcConnection.java:1624) + at com.intellij.database.remote.jdbc.impl.RemoteConnectionImpl.setClientInfo(RemoteConnectionImpl.java:468) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360) + at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200) + at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197) + at java.base/java.security.AccessController.doPrivileged(AccessController.java:714) + at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196) + at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721) + at java.base/java.security.AccessController.doPrivileged(AccessController.java:400) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) + at java.base/java.lang.Thread.run(Thread.java:1583) +2026-04-22 16:39:21.684624Z jdbc[3]: exception +java.sql.SQLClientInfoException: Client info name 'ApplicationName' not supported. + at org.h2.jdbc.JdbcConnection.setClientInfo(JdbcConnection.java:1624) + at com.intellij.database.remote.jdbc.impl.RemoteConnectionImpl.setClientInfo(RemoteConnectionImpl.java:468) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360) + at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200) + at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197) + at java.base/java.security.AccessController.doPrivileged(AccessController.java:714) + at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196) + at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721) + at java.base/java.security.AccessController.doPrivileged(AccessController.java:400) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) + at java.base/java.lang.Thread.run(Thread.java:1583) +2026-04-24 05:52:19.361391+01:00 jdbc[3]: exception +org.h2.jdbc.JdbcSQLNonTransientException: The object is already closed [90007-224] diff --git a/pom.xml b/pom.xml index 8b415da1..db8dc06e 100644 --- a/pom.xml +++ b/pom.xml @@ -8,7 +8,7 @@ org.springframework.boot spring-boot-starter-parent - 3.2.0 + 3.5.0 @@ -53,5 +53,13 @@ lombok true + + + + org.springframework.boot + spring-boot-starter-test + test + + diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 819ffce0..4ea6c307 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -1,6 +1,6 @@ spring: datasource: - url: jdbc:h2:file:./data/mydb + url: jdbc:h2:file:./data/url-shorten-db;AUTO_SERVER=TRUE;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: diff --git a/src/test/java/com/tpximpact/urlshortener/controller/UrlControllerIntegrationTest.java b/src/test/java/com/tpximpact/urlshortener/controller/UrlControllerIntegrationTest.java new file mode 100644 index 00000000..a2d7cbbc --- /dev/null +++ b/src/test/java/com/tpximpact/urlshortener/controller/UrlControllerIntegrationTest.java @@ -0,0 +1,76 @@ +package com.tpximpact.urlshortener.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tpximpact.urlshortener.dto.UrlRequest; +import org.junit.jupiter.api.DisplayName; +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.test.web.servlet.MockMvc; + +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.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class UrlControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("Full integration flow: Create, Redirect, List, and Delete") + void testUrlLifecycle() throws Exception { + String longUrl = "https://www.tpximpact.com"; + String alias = "tpx"; + UrlRequest request = new UrlRequest(longUrl, alias); + + // 1. POST /shorten - Create a new mapping + mockMvc.perform(post("/shorten") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.shortUrl").exists()); + + // 2. GET /{alias} - Test redirection logic + mockMvc.perform(get("/{alias}", alias)) + .andExpect(status().isFound()) // 302 + .andExpect(header().string("Location", longUrl)) + .andExpect(content().string("")); + + // 3. GET /urls - Verify the alias appears in the list + mockMvc.perform(get("/urls")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[?(@.alias == '" + alias + "')]").exists()); + + // 4. DELETE /{alias} - Remove the mapping + mockMvc.perform(delete("/{alias}", alias)) + .andExpect(status().isNoContent()); + + // 5. GET /{alias} - Verify it no longer exists (assuming service throws 404) + mockMvc.perform(get("/{alias}", alias)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("POST /shorten - Should return 400 when fullUrl is blank") + void shouldReturn400ForInvalidInput() throws Exception { + UrlRequest invalidRequest = new UrlRequest("", "short"); + + mockMvc.perform(post("/shorten") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()); + } +} \ No newline at end of file diff --git a/src/test/java/com/tpximpact/urlshortener/controller/UrlControllerTest.java b/src/test/java/com/tpximpact/urlshortener/controller/UrlControllerTest.java new file mode 100644 index 00000000..41a2cffb --- /dev/null +++ b/src/test/java/com/tpximpact/urlshortener/controller/UrlControllerTest.java @@ -0,0 +1,125 @@ +package com.tpximpact.urlshortener.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tpximpact.urlshortener.dto.UrlListResponse; +import com.tpximpact.urlshortener.dto.UrlRequest; +import com.tpximpact.urlshortener.dto.UrlResponse; +import com.tpximpact.urlshortener.exception.NotFoundException; +import com.tpximpact.urlshortener.service.UrlService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; // SB 4.0 standard +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(UrlController.class) +class UrlControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private UrlService urlService; + + @Test + @DisplayName("POST /shorten - Success") + void shortenUrl_Returns201() throws Exception { + // Given + UrlRequest request = new UrlRequest("https://example.com", "my-alias"); + UrlResponse response = new UrlResponse("http://localhost:8080/my-alias"); + + when(urlService.shorten(any(UrlRequest.class))).thenReturn(response); + + // When & Then + mockMvc.perform(post("/shorten") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.shortUrl").value("http://localhost:8080/my-alias")); + + verify(urlService, times(1)).shorten(any(UrlRequest.class)); + } + + @Test + @DisplayName("GET /{alias} - Redirects to full URL") + void redirect_Returns302() throws Exception { + // Given + String alias = "tpx"; + String fullUrl = "https://tpximpact.com"; + when(urlService.getFullUrl(alias)).thenReturn(fullUrl); + + // When & Then + mockMvc.perform(get("/{alias}", alias)) + .andExpect(status().isFound()) + .andExpect(header().string("Location", fullUrl)) + .andExpect(content().string("")); // Asserting empty body as requested + + verify(urlService).getFullUrl(alias); + } + + @Test + @DisplayName("GET /{alias} - Redirects returns 404 if alias not exist") + void redirect_Returns404_If_Alias_Not_Exist() throws Exception { + // Given + String aliasNotExist = "notExist"; + when(urlService.getFullUrl(aliasNotExist)).thenThrow(NotFoundException.class); + + // When & Then + mockMvc.perform(get("/{alias}", aliasNotExist)) + .andExpect(status().isNotFound()); + + verify(urlService).getFullUrl(aliasNotExist); + } + + @Test + @DisplayName("GET /urls - Returns list of mappings") + void getAll_Returns200() throws Exception { + // Given + List mockList = List.of( + new UrlListResponse("alias1", "https://site1.com", "http://short/1"), + new UrlListResponse("alias2", "https://site2.com", "http://short/2") + ); + when(urlService.getAll()).thenReturn(mockList); + + // When & Then + mockMvc.perform(get("/urls")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].alias").value("alias1")); + } + + @Test + @DisplayName("DELETE /{alias} - Success") + void delete_Returns204() throws Exception { + // When & Then + mockMvc.perform(delete("/{alias}", "old-alias")) + .andExpect(status().isNoContent()); + + verify(urlService).delete("old-alias"); + } + + @Test + @DisplayName("POST /shorten - Fails on invalid input") + void shortenUrl_BlankUrl_Returns400() throws Exception { + UrlRequest invalidRequest = new UrlRequest("", "alias"); + + mockMvc.perform(post("/shorten") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(urlService); + } +} \ No newline at end of file diff --git a/src/test/java/com/tpximpact/urlshortener/controller/UrlControllerWIT.java b/src/test/java/com/tpximpact/urlshortener/controller/UrlControllerWIT.java new file mode 100644 index 00000000..f2973462 --- /dev/null +++ b/src/test/java/com/tpximpact/urlshortener/controller/UrlControllerWIT.java @@ -0,0 +1,90 @@ +package com.tpximpact.urlshortener.controller; + +import com.tpximpact.urlshortener.dto.UrlListResponse; +import com.tpximpact.urlshortener.dto.UrlRequest; +import com.tpximpact.urlshortener.dto.UrlResponse; +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.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class UrlControllerWIT { + @Autowired + private TestRestTemplate restTemplate; + + + @Test + @DisplayName("POST /shorten - Should create short URL") + void shouldCreateShortUrl() { + // Given + UrlRequest request = new UrlRequest("https://tpximpact.com", "tpx"); + + // When + ResponseEntity response = restTemplate.postForEntity( + "/shorten", + request, + UrlResponse.class + ); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().shortUrl()).contains("/tpx"); + } + + @Test + @DisplayName("GET /{alias} - Should return 302 Redirect") + void shouldRedirect() { + // First, ensure the URL exists (Seed the data) + restTemplate.postForEntity("/shorten", new UrlRequest("https://google.com", "goog"), UrlResponse.class); + + // When - We use getForEntity + ResponseEntity response = restTemplate.getForEntity("/{alias}", Void.class, "goog"); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("GET /urls - Should return list using TestRestTemplate") + void getAllRestTemplate() { + ResponseEntity response = restTemplate.getForEntity( + "/urls", + UrlListResponse[].class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + + // Convert array to list for easier assertions + List urls = Arrays.asList(response.getBody()); + assertThat(urls).isNotEmpty(); + assertThat(urls.get(0).alias()).isEqualTo("goog"); + + + } + + @Test + @DisplayName("DELETE /{alias} - Should remove entry") + void shouldDelete() { + // When + restTemplate.delete("/{alias}", "tpx"); + + // Then - Verify 404 after deletion + ResponseEntity response = restTemplate.getForEntity("/{alias}", Void.class, "tpx"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + +} diff --git a/src/test/java/com/tpximpact/urlshortener/service/UrlServiceImplTest.java b/src/test/java/com/tpximpact/urlshortener/service/UrlServiceImplTest.java new file mode 100644 index 00000000..555efae4 --- /dev/null +++ b/src/test/java/com/tpximpact/urlshortener/service/UrlServiceImplTest.java @@ -0,0 +1,92 @@ +package com.tpximpact.urlshortener.service; + +import com.tpximpact.urlshortener.dto.UrlRequest; +import com.tpximpact.urlshortener.entity.Url; +import com.tpximpact.urlshortener.exception.BadRequestException; +import com.tpximpact.urlshortener.exception.NotFoundException; +import com.tpximpact.urlshortener.repository.UrlRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import 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(org.mockito.junit.jupiter.MockitoExtension.class) +class UrlServiceImplTest { + + @Mock + private UrlRepository repository; + + @InjectMocks + private UrlServiceImpl service; + + private UrlRequest request; + private Url url; + + @BeforeEach + public void setUp() { + request = new UrlRequest("https://google.com", "alias1"); + + url = Url.builder() + .alias("alias1") + .fullUrl("https://google.com") + .build(); + } + + @Test + void shouldCreateShortUrl_withCustomAlias() { + + when(repository.existsByAlias("alias1")).thenReturn(false); + + var response = service.shorten(request); + + assertThat(response.shortUrl()).contains("alias1"); + verify(repository).existsByAlias("alias1"); + } + + @Test + void shouldThrowException_whenAliasAlreadyExists() { + + when(repository.existsByAlias("alias1")).thenReturn(true); + + assertThatThrownBy(() -> service.shorten(request)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Invalid input or alias already taken"); + } + + @Test + void shouldReturnFullUrl_whenAliasExists() { + + when(repository.findByAlias("alias1")).thenReturn(Optional.of(url)); + + var result = service.getFullUrl("alias1"); + + assertThat(result).isEqualTo("https://google.com"); + } + + @Test + void shouldThrowNotFound_whenAliasMissing() { + when(repository.findByAlias("abc")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getFullUrl("abc")) + .isInstanceOf(NotFoundException.class); + } + + @Test + void shouldDelete_whenAliasExists() { + Url url = Url.builder().alias("abc").build(); + + when(repository.findByAlias("abc")).thenReturn(Optional.of(url)); + + service.delete("abc"); + + verify(repository).delete(url); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 00000000..5421926e --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,10 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb + driverClassName: org.h2.Driver + jpa: + hibernate: + ddl-auto: create-drop + h2: + console: + enabled: true \ No newline at end of file