Archived
Rewrite on the Bennett platform: Spring Boot + Vite/React SPA
Replaces the Next.js app. Same site, same look; the parts that were decisions rather than markup now live in Java. - catalogue, curated order, category filter and image URLs move from a TypeScript array into Postgres behind /api/products and /api/categories - contact form uses the shared platform-starter-contact: validate, RECORD, send, then fan out to n8n. Recording first means a relay outage costs a notification, not an enquiry - PageMetaController rewrites title/description/OG per route, replacing what Next's SSR gave crawlers and link-preview scrapers - 50MB of photos leave the repo for the MinIO bucket, re-encoded to webp (14MB) with EXIF (including phone GPS) stripped - fixes a catalogue typo: 'Strawberry Pie' was category 'Pies', which no filter matched, so it was unreachable unless browsing All
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
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;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mail.MailSendException;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.context.WebApplicationContext;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
import com.itsthevine.web.domain.ContactEnquiry;
|
||||
import com.itsthevine.web.domain.ContactEnquiryRepository;
|
||||
|
||||
@SpringBootTest
|
||||
@Testcontainers
|
||||
class ContactControllerTest {
|
||||
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES =
|
||||
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
||||
|
||||
@DynamicPropertySource
|
||||
static void datasource(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
||||
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
||||
// Activates the contact starter without pointing it at anything real.
|
||||
registry.add("platform.contact.to", () -> "[email protected]");
|
||||
registry.add("platform.contact.from", () -> "[email protected]");
|
||||
}
|
||||
|
||||
/** Nothing in a test run may reach a real relay. */
|
||||
@MockitoBean
|
||||
JavaMailSender mailSender;
|
||||
|
||||
// Boot 4's starter-test no longer ships @AutoConfigureMockMvc, so build MockMvc from the context
|
||||
// directly — it's plain spring-test and needs no extra module.
|
||||
@Autowired
|
||||
WebApplicationContext context;
|
||||
|
||||
@Autowired
|
||||
ContactEnquiryRepository enquiries;
|
||||
|
||||
MockMvc mvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mvc = MockMvcBuilders.webAppContextSetup(context).build();
|
||||
enquiries.deleteAll();
|
||||
}
|
||||
|
||||
private static String body(String name, String email, String message) {
|
||||
return """
|
||||
{"name":"%s","email":"%s","message":"%s"}
|
||||
""".formatted(name, email, message);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsAnEnquiryEmailsItAndRecordsItAsDelivered() throws Exception {
|
||||
mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body("Ada", "[email protected]", "Do you do wedding cakes?")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.ok").value(true));
|
||||
|
||||
verify(mailSender).send(any(SimpleMailMessage.class));
|
||||
|
||||
assertThat(enquiries.findAll()).singleElement().satisfies(e -> {
|
||||
assertThat(e.getName()).isEqualTo("Ada");
|
||||
assertThat(e.getEmail()).isEqualTo("[email protected]");
|
||||
assertThat(e.getMessage()).isEqualTo("Do you do wedding cakes?");
|
||||
assertThat(e.isDelivered()).isTrue();
|
||||
assertThat(e.getCreatedAt()).isNotNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsJunkWithoutStoringItOrEmailing() throws Exception {
|
||||
mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body("Ada", "not-an-address", "hello")))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("That email address does not look right."));
|
||||
|
||||
verifyNoInteractions(mailSender);
|
||||
assertThat(enquiries.findAll()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsTheEnquiryWhenTheRelayIsDown() throws Exception {
|
||||
// The whole reason the row is written before delivery: a broken relay must not lose business.
|
||||
doThrow(new MailSendException("relay down")).when(mailSender).send(any(SimpleMailMessage.class));
|
||||
|
||||
mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body("Ada", "[email protected]", "Cinnamon rolls for 30?")))
|
||||
.andExpect(status().isBadGateway())
|
||||
.andExpect(jsonPath("$.error").value("Could not send the message."));
|
||||
|
||||
assertThat(enquiries.findAll())
|
||||
.singleElement()
|
||||
.extracting(ContactEnquiry::isDelivered, ContactEnquiry::getMessage)
|
||||
.containsExactly(false, "Cinnamon rolls for 30?");
|
||||
}
|
||||
|
||||
@Test
|
||||
void trimsBeforeStoring() throws Exception {
|
||||
mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body(" Ada ", " [email protected] ", " hello ")))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
assertThat(enquiries.findAll()).singleElement().satisfies(e -> {
|
||||
assertThat(e.getName()).isEqualTo("Ada");
|
||||
assertThat(e.getEmail()).isEqualTo("[email protected]");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
/**
|
||||
* Runs against the REAL frontend/index.html rather than a fixture: the controller finds its tags by
|
||||
* pattern, so reformatting that file is exactly how this would silently break. Here it fails the build
|
||||
* instead.
|
||||
*/
|
||||
class PageMetaControllerTest {
|
||||
|
||||
private static final File INDEX = new File("frontend/index.html");
|
||||
|
||||
private static PageMetaController controller() {
|
||||
DefaultResourceLoader loader = new DefaultResourceLoader() {
|
||||
@Override
|
||||
public Resource getResource(String location) {
|
||||
return new FileSystemResource(INDEX);
|
||||
}
|
||||
};
|
||||
return new PageMetaController(loader, "https://itsthevine.com");
|
||||
}
|
||||
|
||||
private static String get(String path) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", path);
|
||||
request.setRequestURI(path);
|
||||
return controller().page(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theIndexTemplateIsWhereTheControllerExpects() {
|
||||
assertThat(INDEX).exists();
|
||||
}
|
||||
|
||||
@Test
|
||||
void productsPageGetsItsOwnTitleAndDescription() {
|
||||
String html = get("/products");
|
||||
|
||||
assertThat(html).contains("<title>Our products · The Vine Coffeehouse + Bakery</title>");
|
||||
assertThat(html).contains("<meta name=\"description\" content=\"Cinnamon rolls, caramel rolls");
|
||||
assertThat(html).contains("<meta property=\"og:title\" content=\"Our products · The Vine");
|
||||
assertThat(html).contains("<meta property=\"og:url\" content=\"https://itsthevine.com/products\">");
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyRouteIsRewritten() {
|
||||
// A route the controller maps but forgot to describe would silently serve the homepage's
|
||||
// metadata, which is worse than none — it tells a crawler two URLs are the same page.
|
||||
assertThat(get("/history")).contains("<title>Our story · ");
|
||||
assertThat(get("/contact")).contains("<title>Contact us · ");
|
||||
assertThat(get("/")).contains("<title>The Vine Coffeehouse + Bakery</title>");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theHomepageOgUrlHasNoTrailingSlash() {
|
||||
assertThat(get("/")).contains("<meta property=\"og:url\" content=\"https://itsthevine.com\">");
|
||||
}
|
||||
|
||||
@Test
|
||||
void noDefaultMetadataSurvivesOnASubPage() {
|
||||
// The template ships with the homepage copy. If a replacement misses, that copy leaks onto
|
||||
// every page and the whole exercise is pointless.
|
||||
String html = get("/contact");
|
||||
assertThat(html).doesNotContain("A locally owned coffeehouse and bakery in downtown Princeville, Illinois. We bake");
|
||||
assertThat(html).doesNotContain("<title>The Vine Coffeehouse + Bakery</title>");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theAppShellIsStillIntact() {
|
||||
// Rewriting the head must not disturb what actually boots the SPA.
|
||||
String html = get("/products");
|
||||
assertThat(html).contains("<div id=\"root\"></div>");
|
||||
assertThat(html).contains("/src/main.tsx");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
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.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
/** Exercises the catalogue against the real seeded data, so the migration is covered too. */
|
||||
@SpringBootTest
|
||||
@Testcontainers
|
||||
class ProductCatalogTest {
|
||||
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES =
|
||||
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
||||
|
||||
@DynamicPropertySource
|
||||
static void datasource(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
||||
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
||||
registry.add("site.assets.base-url", () -> "https://s3.example.test/itsthevine");
|
||||
}
|
||||
|
||||
@Autowired
|
||||
ProductCatalog catalog;
|
||||
|
||||
@Test
|
||||
void theWholeCatalogueSurvivedTheMigrationFromTypeScript() {
|
||||
assertThat(catalog.list(null)).hasSize(40);
|
||||
assertThat(catalog.list("All")).hasSize(40);
|
||||
assertThat(catalog.list(null).stream().mapToLong(p -> p.images().size()).sum()).isEqualTo(80);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsTheCuratedOrderRatherThanIdOrAlphabetical() {
|
||||
List<ProductCatalog.ProductView> all = catalog.list(null);
|
||||
assertThat(all.get(0).name()).isEqualTo("76th Birthday Cake");
|
||||
assertThat(all).extracting(ProductCatalog.ProductView::name).doesNotHaveDuplicates();
|
||||
}
|
||||
|
||||
@Test
|
||||
void filtersByCategoryServerSide() {
|
||||
List<ProductCatalog.ProductView> cakes = catalog.list("Cakes");
|
||||
assertThat(cakes).isNotEmpty();
|
||||
assertThat(cakes).allSatisfy(p -> assertThat(p.category()).isEqualTo("Cakes"));
|
||||
assertThat(cakes).hasSizeLessThan(40);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownCategoryIsEmptyRatherThanEverything() {
|
||||
// Returning the full catalogue for a bad filter would quietly lie about what's in it.
|
||||
assertThat(catalog.list("Sourdough")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void offersTheFilterButtonsInTheOrderTheSiteAlwaysUsed() {
|
||||
assertThat(catalog.categories())
|
||||
.containsExactly("All", "Cookies", "Cakes", "Rolls", "Pie", "Brownies", "Pastries");
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildsAbsoluteImageUrlsAndEncodesSpaces() {
|
||||
List<String> images = catalog.list(null).stream().flatMap(p -> p.images().stream()).toList();
|
||||
assertThat(images).allSatisfy(url ->
|
||||
assertThat(url).startsWith("https://s3.example.test/itsthevine/images/"));
|
||||
// A raw space would not fetch; '+' (form encoding) would 404 against the bucket.
|
||||
assertThat(images).noneMatch(url -> url.contains(" ") || url.contains("+"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user