Archived
build-and-publish / build (pull_request) Successful in 2m14s
Two unrelated things the bakery hit on the same afternoon. THE FAVICON was not missing, it was unusable. The head pointed rel=icon at the 1000x1000 logo PNGs, so a browser fetched 71 KB to paint 16 square pixels, and the mark is a vine branch drawn in hairlines -- strokes thinner than one pixel at that size -- which arrives as a grey smudge. Replaced with a real icon set built from one leaf of that branch, filled rather than stroked, because at 16px a silhouette survives and an outline does not. A midrib was drawn first and cut the leaf into two pale slivers at tab size, so it went; the tilt, the two points and the stem carry the shape. The SVG answers prefers-color-scheme itself, which a .ico cannot, so the dark tab strip gets sage on bakery-900 instead of a glowing cream tile. The .ico is listed first on purpose: a browser takes the last format it understands, so reversing the two would hand Chrome the bitmap. PHOTO UPLOADS failed on anything over 1 MB, which is every photo a phone takes. The cause was an absence: nothing configured spring.servlet.multipart, so Boot's 1 MB default applied and the container rejected the file with FileSizeLimitExceededException before it reached ProductPhotoService -- the class whose entire job is turning "whatever came off a phone" into a resized webp. The pipeline could never run on the input it was written for. Now 15 MB a file and 60 MB a request, the latter because the file input is `multiple`. The failure was also ugly, and that is fixed separately: parsed eagerly, an over-sized part throws from inside Tomcat's parameter parsing where no @ExceptionHandler can reach it, so the request died as a 500 and then died again forwarding to /error, because that forward re-parsed the same too-large request (the paired "Exception Processing [ErrorPage...]" lines in the log). resolve-lazily moves the throw into argument binding, where AdminController now catches it and returns the same `problem` flash the domain's other refusals use. max-swallow-size lets the body be discarded so the browser receives that redirect rather than a connection reset. The multipart numbers are asserted rather than trusted, because a default that was never set is exactly the kind of thing that comes back silently. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
217 lines
12 KiB
Java
217 lines
12 KiB
Java
package com.itsthevine.web;
|
||
|
||
import static org.hamcrest.Matchers.containsString;
|
||
import static org.hamcrest.Matchers.not;
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
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.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.boot.testcontainers.service.connection.ServiceConnection;
|
||
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;
|
||
|
||
/**
|
||
* The pages, rendered.
|
||
*
|
||
* <p>These assert what a visitor and a crawler are actually served — the catalogue in the HTML rather
|
||
* than in a JSON call the page makes later, and a real per-page {@code <title>}. That second one is the
|
||
* whole reason {@code PageMetaController} existed; this replaces its test.
|
||
*
|
||
* <p>They are also the only thing that catches a broken template: a Thymeleaf expression that names a
|
||
* model attribute wrongly fails at render time, not at compile time.
|
||
*/
|
||
@SpringBootTest(properties = {
|
||
"platform.storage.access-key=test",
|
||
"platform.storage.secret-key=test",
|
||
"[email protected]",
|
||
"[email protected]",
|
||
"site.base-url=https://itsthevine.test",
|
||
"site.assets.base-url=https://s3.example.test/itsthevine"
|
||
})
|
||
@Testcontainers
|
||
class SiteControllerTest {
|
||
|
||
@Container
|
||
@ServiceConnection
|
||
static PostgreSQLContainer<?> postgres =
|
||
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
||
|
||
@Autowired
|
||
WebApplicationContext context;
|
||
|
||
@Autowired
|
||
com.itsthevine.web.domain.ContactEnquiryRepository enquiries;
|
||
|
||
MockMvc mvc;
|
||
|
||
@BeforeEach
|
||
void setUp() {
|
||
mvc = MockMvcBuilders.webAppContextSetup(context).build();
|
||
}
|
||
|
||
/**
|
||
* The tab icon used to be the 1000x1000 logo PNG, which is 71 KB fetched to paint 16 pixels and, being
|
||
* a hairline drawing, arrived as a grey smudge at that size. The order matters and is the reason this
|
||
* asserts position: a browser takes the LAST icon format it understands, so the .ico has to come first
|
||
* or Chrome settles for the bitmap instead of the SVG.
|
||
*/
|
||
@Test
|
||
void theTabIconIsAnIconRatherThanTheFullLogo() throws Exception {
|
||
String head = mvc.perform(get("/")).andExpect(status().isOk())
|
||
.andReturn().getResponse().getContentAsString();
|
||
|
||
assertThat(head).contains("<link rel=\"icon\" href=\"/favicon.ico\" sizes=\"32x32\">");
|
||
assertThat(head).contains("<link rel=\"icon\" href=\"/favicon.svg\" type=\"image/svg+xml\">");
|
||
assertThat(head).contains("<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">");
|
||
assertThat(head.indexOf("/favicon.ico")).isLessThan(head.indexOf("/favicon.svg"));
|
||
// The 1000x1000 logos are no longer offered as icons anywhere.
|
||
assertThat(head).doesNotContain("rel=\"icon\" media=");
|
||
assertThat(head).doesNotContain("logo_dark.png");
|
||
}
|
||
|
||
@Test
|
||
void theIconFilesAreActuallyServed() throws Exception {
|
||
// A link to a 404 is worse than no link: the browser shows its default and caches the miss.
|
||
for (String icon : new String[] {"/favicon.svg", "/favicon.ico", "/apple-touch-icon.png"}) {
|
||
mvc.perform(get(icon)).andExpect(status().isOk());
|
||
}
|
||
}
|
||
|
||
@Test
|
||
void everyPageStatesItsOwnTitleAndDescription() throws Exception {
|
||
// One generic shell for every page was the SPA's problem, and the reason a controller used to
|
||
// rewrite the head with regular expressions.
|
||
mvc.perform(get("/")).andExpect(status().isOk())
|
||
.andExpect(content().string(containsString("<title>The Vine Coffeehouse + Bakery</title>")))
|
||
.andExpect(content().string(containsString("A locally owned coffeehouse and bakery")))
|
||
.andExpect(content().string(containsString("og:url\" content=\"https://itsthevine.test\"")));
|
||
mvc.perform(get("/products"))
|
||
.andExpect(content().string(containsString("<title>Our products · The Vine Coffeehouse + Bakery</title>")))
|
||
.andExpect(content().string(containsString("og:url\" content=\"https://itsthevine.test/products\"")));
|
||
mvc.perform(get("/catering"))
|
||
.andExpect(content().string(containsString("<title>Goodie boxes & catering · The Vine Coffeehouse + Bakery</title>")));
|
||
mvc.perform(get("/history"))
|
||
.andExpect(content().string(containsString("<title>Our story · The Vine Coffeehouse + Bakery</title>")));
|
||
mvc.perform(get("/contact"))
|
||
.andExpect(content().string(containsString("<title>Contact us · The Vine Coffeehouse + Bakery</title>")));
|
||
}
|
||
|
||
@Test
|
||
void theCatalogueIsInTheHtmlRatherThanFetchedAfterwards() throws Exception {
|
||
mvc.perform(get("/products")).andExpect(status().isOk())
|
||
// A real product, its category, and a photo URL built from the bucket config.
|
||
.andExpect(content().string(containsString("76th Birthday Cake")))
|
||
.andExpect(content().string(containsString("https://s3.example.test/itsthevine/images/")))
|
||
// The filter buttons are links now, so every filtered view is a URL a crawler can follow.
|
||
.andExpect(content().string(containsString("href=\"/products?category=Cakes\"")));
|
||
}
|
||
|
||
@Test
|
||
void theCategoryFilterIsAppliedByTheServer() throws Exception {
|
||
mvc.perform(get("/products").param("category", "Pie")).andExpect(status().isOk())
|
||
.andExpect(content().string(containsString("Blueberry Cream Pie")))
|
||
.andExpect(content().string(not(containsString("76th Birthday Cake"))))
|
||
// The chosen one is marked for a screen reader, not just coloured in.
|
||
.andExpect(content().string(containsString("aria-current=\"page\"")));
|
||
}
|
||
|
||
@Test
|
||
void theCateringTablesAreRenderedFromTheDatabase() throws Exception {
|
||
mvc.perform(get("/catering")).andExpect(status().isOk())
|
||
.andExpect(content().string(containsString("Office boxes")))
|
||
.andExpect(content().string(containsString("Weddings")))
|
||
// V8's new table, filling the gap between a forty-person party and a wedding.
|
||
.andExpect(content().string(containsString("Gatherings")))
|
||
// Prices as the server writes them — the page never formats money.
|
||
.andExpect(content().string(containsString("$24")))
|
||
.andExpect(content().string(containsString("$298")))
|
||
.andExpect(content().string(containsString("$110")))
|
||
// A cell and a note, in the wording a customer reads rather than the spreadsheet's.
|
||
// The office box is a total mixed in sixes, not three separate things.
|
||
.andExpect(content().string(containsString("Any mix of mini muffins")))
|
||
.andExpect(content().string(containsString("18 items")))
|
||
.andExpect(content().string(containsString("About 6–8 people")))
|
||
.andExpect(content().string(containsString("Baked in sixes")))
|
||
// One card per size, and one enquiry link per table — not one per card, which would have
|
||
// read "Ask about the 15–20 people".
|
||
// The size label is in the HTML as written; the small caps are CSS.
|
||
.andExpect(content().string(containsString("Large")))
|
||
.andExpect(content().string(containsString("Ask about office boxes")))
|
||
// The price grid is gone: it was the source spreadsheet, rendered.
|
||
.andExpect(content().string(not(containsString("<table"))));
|
||
}
|
||
|
||
@Test
|
||
void thePagesShareOneHeaderThatOffersTheCateringPage() throws Exception {
|
||
// A page nobody can navigate to isn't finished.
|
||
mvc.perform(get("/")).andExpect(content().string(containsString("href=\"/catering\"")));
|
||
mvc.perform(get("/products")).andExpect(content().string(containsString("href=\"/catering\"")));
|
||
}
|
||
|
||
@Test
|
||
void theNavMarksThePageYouAreOn() throws Exception {
|
||
// Generated from the path the controller set, so a wrong model attribute would mark nothing at
|
||
// all — and nothing at all looks exactly like a page that simply has no active link.
|
||
mvc.perform(get("/catering"))
|
||
.andExpect(content().string(containsString("href=\"/catering\" aria-current=\"page\"")));
|
||
mvc.perform(get("/products"))
|
||
.andExpect(content().string(containsString("href=\"/products\" aria-current=\"page\"")))
|
||
.andExpect(content().string(not(containsString("href=\"/catering\" aria-current"))));
|
||
}
|
||
|
||
@Test
|
||
void everyPageOffersTheKeyboardAWayPastTheNav() throws Exception {
|
||
mvc.perform(get("/"))
|
||
.andExpect(content().string(containsString("href=\"#content\"")))
|
||
.andExpect(content().string(containsString("id=\"content\"")));
|
||
}
|
||
|
||
@Test
|
||
void aCateringButtonStartsTheEnquiryOffAboutThatTable() throws Exception {
|
||
mvc.perform(get("/contact").param("about", "Weddings"))
|
||
.andExpect(content().string(containsString("like to ask about weddings catering")));
|
||
}
|
||
|
||
@Test
|
||
void anAboutParameterThatIsntATableIsIgnored() throws Exception {
|
||
// The value lands in a box on the page, so it is matched against the real table names rather
|
||
// than echoed. Thymeleaf escapes it either way; a link that puts someone else's words in front
|
||
// of a customer still shouldn't work.
|
||
mvc.perform(get("/contact").param("about", "<script>alert(1)</script>"))
|
||
.andExpect(status().isOk())
|
||
.andExpect(content().string(not(containsString("<script>alert(1)</script>"))))
|
||
.andExpect(content().string(not(containsString("like to ask about"))));
|
||
}
|
||
|
||
@Test
|
||
void aFilledInTrapIsThankedAndThrownAway() throws Exception {
|
||
// The bot is told the same thing a person is told — anything else is a training signal — and
|
||
// nothing is recorded or sent. The enquiry table is what proves the second half.
|
||
mvc.perform(post("/contact")
|
||
.param("name", "Bot").param("email", "[email protected]")
|
||
.param("message", "Cheap watches").param("website", "http://example.com"))
|
||
.andExpect(status().isOk())
|
||
.andExpect(content().string(containsString("Your message is on its way")));
|
||
assertThat(enquiries.count()).isZero();
|
||
}
|
||
|
||
@Test
|
||
void anUnknownPageIsNotFound() throws Exception {
|
||
// Status only: MockMvc does not run the servlet container's error dispatch, so the body of the
|
||
// rendered error/404.html page can't be asserted here. It is checked against a running container
|
||
// instead — the page itself is a template like any other, and the layout it uses is covered above.
|
||
mvc.perform(get("/no-such-page")).andExpect(status().isNotFound());
|
||
}
|
||
}
|