Skip to content

Commit 78c50f6

Browse files
committed
next steps
1 parent 0821683 commit 78c50f6

15 files changed

Lines changed: 374 additions & 206 deletions

labs/lab-5/src/main/java/pragmatech/digital/workshops/lab5/service/BookService.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import org.slf4j.Logger;
77
import org.slf4j.LoggerFactory;
88
import org.springframework.stereotype.Service;
9+
import org.springframework.transaction.annotation.Transactional;
910
import pragmatech.digital.workshops.lab5.client.OpenLibraryApiClient;
1011
import pragmatech.digital.workshops.lab5.dto.BookCreationRequest;
1112
import pragmatech.digital.workshops.lab5.dto.BookMetadataResponse;
@@ -15,10 +16,9 @@
1516
import pragmatech.digital.workshops.lab5.repository.BookRepository;
1617

1718
@Service
19+
@Transactional
1820
public class BookService {
1921

20-
private static final Logger logger = LoggerFactory.getLogger(BookService.class);
21-
2222
private final BookRepository bookRepository;
2323
private final OpenLibraryApiClient openLibraryApiClient;
2424

labs/lab-5/src/test/java/pragmatech/digital/workshops/lab5/exercises/Exercise1MockMvcIntegrationTest.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,9 @@ void shouldCreateAndRetrieveBookWhenUsingMockMvc() {
7272
@Test
7373
void shouldReturnAllBooksWhenUsingMockMvc() {
7474
// TODO:
75+
// 0. Pre-populate the database with some books
7576
// 1. Perform a GET to /api/books (this endpoint is publicly accessible)
76-
//
7777
// 2. Assert the response status is 200 OK
78-
//
7978
// 3. Assert the response body is a JSON array using jsonPath("$").isArray()
8079
}
8180

labs/lab-5/src/test/java/pragmatech/digital/workshops/lab5/exercises/Exercise2WebTestClientIntegrationTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ void shouldCreateAndRetrieveBookWhenUsingWebTestClient() {
8080
@Test
8181
void shouldReturnAllBooksWhenUsingWebTestClient() {
8282
// TODO:
83+
// 0. Pre-populate the database with some books
8384
// 1. Perform a GET to /api/books (publicly accessible, no auth needed)
8485
// Use webTestClient.get().uri("/api/books")
8586
//

labs/lab-5/src/test/java/pragmatech/digital/workshops/lab5/experiment/ContextCustomisationTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import org.springframework.context.annotation.Import;
1212
import org.springframework.http.MediaType;
1313
import org.springframework.security.test.context.support.WithMockUser;
14+
import org.springframework.test.context.ActiveProfiles;
1415
import org.springframework.test.web.servlet.MockMvc;
1516
import org.springframework.test.web.servlet.MvcResult;
1617
import org.springframework.transaction.annotation.Transactional;
@@ -48,6 +49,7 @@
4849
)
4950
@AutoConfigureMockMvc
5051
@Transactional
52+
@ActiveProfiles("expensive-tests")
5153
@Import(LocalDevTestcontainerConfig.class)
5254
class ContextCustomisationTest {
5355

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package pragmatech.digital.workshops.lab5.experiment.customizer;
2+
3+
import java.time.LocalDate;
4+
5+
import org.junit.jupiter.api.AfterEach;
6+
import org.junit.jupiter.api.BeforeEach;
7+
import org.junit.jupiter.api.Test;
8+
import org.springframework.beans.factory.annotation.Autowired;
9+
import org.springframework.boot.test.context.SpringBootTest;
10+
import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient;
11+
import org.springframework.http.MediaType;
12+
import org.springframework.test.web.reactive.server.WebTestClient;
13+
import pragmatech.digital.workshops.lab5.config.OpenLibraryApiStub;
14+
import pragmatech.digital.workshops.lab5.entity.Book;
15+
import pragmatech.digital.workshops.lab5.repository.BookRepository;
16+
17+
@SharedInfrastructureTest
18+
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
19+
@AutoConfigureWebTestClient
20+
class BookControllerCustomizerIT {
21+
22+
@Autowired
23+
private WebTestClient webTestClient;
24+
25+
@Autowired
26+
private BookRepository bookRepository;
27+
28+
@Autowired
29+
private OpenLibraryApiStub openLibraryApiStub;
30+
31+
@AfterEach
32+
void cleanUp() {
33+
bookRepository.deleteAll();
34+
}
35+
36+
@BeforeEach
37+
void setUp() {
38+
bookRepository.save(new Book("111-1234567890", "Clean Code", "Robert C. Martin", LocalDate.of(2008, 8, 1)));
39+
bookRepository.save(new Book("222-1234567890", "Effective Java", "Joshua Bloch", LocalDate.of(2018, 1, 6)));
40+
}
41+
42+
@Test
43+
void shouldReturnAllBooksWhenGettingAllBooks() {
44+
webTestClient.get()
45+
.uri("/api/books")
46+
.accept(MediaType.APPLICATION_JSON)
47+
.exchange()
48+
.expectStatus().isOk()
49+
.expectBody()
50+
.jsonPath("$").isArray()
51+
.jsonPath("$.length()").isEqualTo(2);
52+
}
53+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package pragmatech.digital.workshops.lab5.experiment.customizer;
2+
3+
import java.time.Clock;
4+
import java.time.Instant;
5+
import java.time.ZoneId;
6+
7+
import com.github.tomakehurst.wiremock.WireMockServer;
8+
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
9+
import org.springframework.boot.test.util.TestPropertyValues;
10+
import org.springframework.context.ConfigurableApplicationContext;
11+
import org.springframework.test.context.ContextCustomizer;
12+
import org.springframework.test.context.MergedContextConfiguration;
13+
import org.testcontainers.postgresql.PostgreSQLContainer;
14+
import pragmatech.digital.workshops.lab5.config.OpenLibraryApiStub;
15+
16+
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
17+
18+
public class SharedInfrastructureContextCustomizer implements ContextCustomizer {
19+
20+
// Static so they persist across multiple test class executions (reusing the context)
21+
private static final PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:16-alpine");
22+
private static final WireMockServer wireMockServer = new WireMockServer(options().dynamicPort());
23+
24+
@Override
25+
public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) {
26+
// 1. Start Infrastructure
27+
postgres.start();
28+
wireMockServer.start();
29+
30+
// 2. Inject Dynamic Properties (JDBC URL, WireMock Port)
31+
TestPropertyValues.of(
32+
"spring.datasource.url=" + postgres.getJdbcUrl(),
33+
"spring.datasource.username=" + postgres.getUsername(),
34+
"spring.datasource.password=" + postgres.getPassword(),
35+
"book.metadata.api.url=http://localhost:" + wireMockServer.port()
36+
).applyTo(context.getEnvironment());
37+
38+
// 3. Register Beans Programmatically
39+
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
40+
41+
OpenLibraryApiStub openLibraryStub = new OpenLibraryApiStub(wireMockServer);
42+
43+
openLibraryStub.stubForSuccessfulBookResponse("9780134757599");
44+
openLibraryStub.stubForSuccessfulBookResponse("9780201633610");
45+
openLibraryStub.stubForSuccessfulBookResponse("9780132350884");
46+
47+
// Register WireMock so tests can @Autowired it to stub responses
48+
beanFactory.registerSingleton("wireMockServer", wireMockServer);
49+
beanFactory.registerSingleton("openLibraryStub", openLibraryStub);
50+
51+
// Register a Fixed Clock for deterministic time-based testing
52+
Clock fixedClock = Clock.fixed(Instant.parse("2026-03-01T10:00:00Z"), ZoneId.of("UTC"));
53+
beanFactory.registerSingleton("clock", fixedClock);
54+
}
55+
56+
// Required for context caching to work correctly
57+
@Override
58+
public boolean equals(Object obj) {
59+
return obj != null && obj.getClass() == this.getClass();
60+
}
61+
62+
@Override
63+
public int hashCode() {
64+
return SharedInfrastructureContextCustomizer.class.hashCode();
65+
}
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package pragmatech.digital.workshops.lab5.experiment.customizer;
2+
3+
import java.util.List;
4+
5+
import org.springframework.core.annotation.AnnotatedElementUtils;
6+
import org.springframework.test.context.ContextConfigurationAttributes;
7+
import org.springframework.test.context.ContextCustomizer;
8+
import org.springframework.test.context.ContextCustomizerFactory;
9+
10+
public class SharedInfrastructureCustomizerFactory implements ContextCustomizerFactory {
11+
12+
@Override
13+
public ContextCustomizer createContextCustomizer(Class<?> testClass, List<ContextConfigurationAttributes> configAttributes) {
14+
// Only activate if the @SharedInfrastructureTest annotation is present
15+
if (AnnotatedElementUtils.hasAnnotation(testClass, SharedInfrastructureTest.class)) {
16+
return new SharedInfrastructureContextCustomizer();
17+
}
18+
19+
// Otherwise, return null (do nothing)
20+
return null;
21+
}
22+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package pragmatech.digital.workshops.lab5.experiment.customizer;
2+
3+
import java.lang.annotation.ElementType;
4+
import java.lang.annotation.Inherited;
5+
import java.lang.annotation.Retention;
6+
import java.lang.annotation.RetentionPolicy;
7+
import java.lang.annotation.Target;
8+
9+
@Target(ElementType.TYPE)
10+
@Retention(RetentionPolicy.RUNTIME)
11+
@Inherited
12+
public @interface SharedInfrastructureTest {
13+
// You can add parameters here, like 'boolean useWireMock() default true'
14+
}

labs/lab-5/src/test/java/pragmatech/digital/workshops/lab5/solutions/Solution1MockMvcIntegrationTest.java

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package pragmatech.digital.workshops.lab5.solutions;
22

3+
import java.time.LocalDate;
4+
35
import org.junit.jupiter.api.BeforeEach;
46
import org.junit.jupiter.api.Test;
57
import org.springframework.beans.factory.annotation.Autowired;
@@ -15,6 +17,8 @@
1517
import pragmatech.digital.workshops.lab5.LocalDevTestcontainerConfig;
1618
import pragmatech.digital.workshops.lab5.config.OpenLibraryApiStub;
1719
import pragmatech.digital.workshops.lab5.config.WireMockContextInitializer;
20+
import pragmatech.digital.workshops.lab5.entity.Book;
21+
import pragmatech.digital.workshops.lab5.repository.BookRepository;
1822

1923
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
2024
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -47,18 +51,14 @@ class Solution1MockMvcIntegrationTest {
4751
@Autowired
4852
private MockMvc mockMvc;
4953

54+
@Autowired
55+
private BookRepository bookRepository;
56+
5057
@Autowired
5158
private OpenLibraryApiStub openLibraryApiStub;
5259

5360
private static final String VALID_ISBN = "978-0134757599";
5461

55-
@BeforeEach
56-
void setUp() {
57-
// Register a WireMock stub for the dashed ISBN format used in BookCreationRequest.
58-
// The BookService passes the ISBN as-is to the OpenLibrary API client,
59-
// so the stub must match the dashed format.
60-
openLibraryApiStub.stubForSuccessfulBookResponse(VALID_ISBN);
61-
}
6262

6363
@Test
6464
@WithMockUser(roles = "USER")
@@ -73,6 +73,8 @@ void shouldCreateAndRetrieveBookWhenUsingMockMvc() throws Exception {
7373
}
7474
""".formatted(VALID_ISBN);
7575

76+
openLibraryApiStub.stubForSuccessfulBookResponse(VALID_ISBN);
77+
7678
// Act - Create a book
7779
MvcResult createResult = mockMvc.perform(post("/api/books")
7880
.contentType(MediaType.APPLICATION_JSON)
@@ -100,10 +102,14 @@ void shouldCreateAndRetrieveBookWhenUsingMockMvc() throws Exception {
100102
@Test
101103
void shouldReturnAllBooksWhenUsingMockMvc() throws Exception {
102104
// GET /api/books is publicly accessible (permitAll)
105+
this.bookRepository.save(new Book("123-1234567890", "Book One", "Author A", LocalDate.now()));
106+
this.bookRepository.save(new Book("456-1234567890", "Book Two", "Author B", LocalDate.now()));
107+
103108
mockMvc.perform(get("/api/books")
104109
.accept(MediaType.APPLICATION_JSON))
105110
.andExpect(status().isOk())
106-
.andExpect(jsonPath("$").isArray());
111+
.andExpect(jsonPath("$").isArray())
112+
.andExpect(jsonPath("$.size()").value(2));
107113
}
108114

109115
@Test

labs/lab-5/src/test/java/pragmatech/digital/workshops/lab5/solutions/Solution2WebTestClientIntegrationTest.java

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
package pragmatech.digital.workshops.lab5.solutions;
22

33
import java.net.URI;
4+
import java.time.LocalDate;
45

56
import org.junit.jupiter.api.AfterEach;
6-
import org.junit.jupiter.api.BeforeEach;
77
import org.junit.jupiter.api.Test;
88
import org.springframework.beans.factory.annotation.Autowired;
99
import org.springframework.boot.test.context.SpringBootTest;
@@ -12,9 +12,11 @@
1212
import org.springframework.http.MediaType;
1313
import org.springframework.test.context.ContextConfiguration;
1414
import org.springframework.test.web.reactive.server.WebTestClient;
15+
import org.springframework.transaction.annotation.Transactional;
1516
import pragmatech.digital.workshops.lab5.LocalDevTestcontainerConfig;
1617
import pragmatech.digital.workshops.lab5.config.OpenLibraryApiStub;
1718
import pragmatech.digital.workshops.lab5.config.WireMockContextInitializer;
19+
import pragmatech.digital.workshops.lab5.entity.Book;
1820
import pragmatech.digital.workshops.lab5.repository.BookRepository;
1921

2022
/**
@@ -49,20 +51,15 @@ class Solution2WebTestClientIntegrationTest {
4951

5052
private static final String VALID_ISBN = "978-0134757599";
5153

52-
@BeforeEach
53-
void setUp() {
54-
// Register a WireMock stub for the dashed ISBN format.
55-
openLibraryApiStub.stubForSuccessfulBookResponse(VALID_ISBN);
56-
}
57-
5854
@AfterEach
5955
void cleanUp() {
6056
// WebTestClient commits transactions in the server thread, so we must
6157
// clean up manually. Without this, data would persist across tests.
62-
bookRepository.deleteAll();
58+
this.bookRepository.deleteAll();
6359
}
6460

6561
@Test
62+
@Transactional
6663
void shouldCreateAndRetrieveBookWhenUsingWebTestClient() {
6764
// Arrange
6865
String requestBody = """
@@ -74,6 +71,8 @@ void shouldCreateAndRetrieveBookWhenUsingWebTestClient() {
7471
}
7572
""".formatted(VALID_ISBN);
7673

74+
openLibraryApiStub.stubForSuccessfulBookResponse(VALID_ISBN);
75+
7776
// Act - Create a book using POST with Basic Auth
7877
URI locationUri = webTestClient.post()
7978
.uri("/api/books")
@@ -109,20 +108,26 @@ void shouldCreateAndRetrieveBookWhenUsingWebTestClient() {
109108
@Test
110109
void shouldReturnAllBooksWhenUsingWebTestClient() {
111110
// GET /api/books is publicly accessible (permitAll)
112-
webTestClient.get()
111+
112+
// this won't work when we use @Trannsactional because the test transaction is separate from the server thread's transaction.
113+
this.bookRepository.save(new Book("123-1234567890", "Book One", "Author A", LocalDate.now()));
114+
this.bookRepository.save(new Book("456-1234567890", "Book Two", "Author B", LocalDate.now()));
115+
116+
this.webTestClient.get()
113117
.uri("/api/books")
114118
.accept(MediaType.APPLICATION_JSON)
115119
.exchange()
116120
.expectStatus().isOk()
117121
.expectBody()
118-
.jsonPath("$").isArray();
122+
.jsonPath("$").isArray()
123+
.jsonPath("$.size()").isEqualTo(2);
119124
}
120125

121126
@Test
122127
void shouldRejectUnauthorizedAccessToProtectedEndpoint() {
123128
// GET /api/books/{id} requires USER role
124129
// Without credentials, the server should return 401
125-
webTestClient.get()
130+
this.webTestClient.get()
126131
.uri("/api/books/1")
127132
.accept(MediaType.APPLICATION_JSON)
128133
.exchange()

0 commit comments

Comments
 (0)