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:
2026-07-22 22:09:51 -05:00
parent 207415dbbe
commit f471462d05
164 changed files with 4223 additions and 14003 deletions
@@ -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("+"));
}
}