Add Open Graph and Twitter Card meta-tags for link previews
All checks were successful
CI / backend-test (push) Successful in 1m9s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m12s
CI / build-and-publish (push) Has been skipped

Replace PathResourceResolver SPA fallback with SpaController that
injects OG/Twitter meta-tags into cached index.html template.
Event pages get event-specific tags (title, date, location),
all other pages get generic fete branding. Includes og-image.png
brand asset and forward-headers-strategy for proxy support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 20:25:39 +01:00
parent fa34223c10
commit 751201617d
16 changed files with 1270 additions and 28 deletions

View File

@@ -0,0 +1,281 @@
package de.fete.adapter.in.web;
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.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import de.fete.TestcontainersConfig;
import de.fete.adapter.out.persistence.EventJpaEntity;
import de.fete.adapter.out.persistence.EventJpaRepository;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.UUID;
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.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfig.class)
class SpaControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private EventJpaRepository eventJpaRepository;
// --- Phase 2: Base functionality ---
@Test
void rootServesHtml() throws Exception {
mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
}
@Test
void rootHtmlDoesNotContainPlaceholder() throws Exception {
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).doesNotContain("<!-- OG_META_TAGS -->");
}
@Test
void createRouteServesHtml() throws Exception {
mockMvc.perform(get("/create").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
}
@Test
void eventsRouteServesHtml() throws Exception {
mockMvc.perform(get("/events").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
}
// --- Phase 4 (US2): Generic OG meta-tags ---
@Test
void rootContainsGenericOgTitle() throws Exception {
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:title");
assertThat(html).contains("content=\"fete\"");
}
@Test
void createRouteContainsGenericOgDescription() throws Exception {
String html = mockMvc.perform(get("/create").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:description");
assertThat(html).contains("Privacy-focused event planning");
}
@Test
void unknownRouteReturns404() throws Exception {
mockMvc.perform(get("/unknown/path").accept(MediaType.TEXT_HTML))
.andExpect(status().isNotFound());
}
// --- Phase 5 (US3): Twitter Card meta-tags ---
@Test
void eventRouteContainsTwitterCardTags() throws Exception {
EventJpaEntity event = seedEvent(
"Twitter Test", "Testing cards",
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("twitter:card");
assertThat(html).contains("twitter:title");
assertThat(html).contains("twitter:description");
}
@Test
void genericRouteContainsTwitterCardTags() throws Exception {
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("twitter:card");
assertThat(html).contains("content=\"summary\"");
}
// --- Phase 3 (US1): Event-specific OG meta-tags ---
@Test
void eventRouteContainsEventSpecificOgTitle() throws Exception {
EventJpaEntity event = seedEvent(
"Birthday Party", "Come celebrate!",
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:title");
assertThat(html).contains("Birthday Party");
}
@Test
void eventRouteContainsOgDescription() throws Exception {
EventJpaEntity event = seedEvent(
"BBQ", "Bring drinks!",
"Europe/Berlin", "Central Park", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:description");
assertThat(html).contains("Central Park");
assertThat(html).contains("Bring drinks!");
}
@Test
void eventRouteContainsOgUrl() throws Exception {
EventJpaEntity event = seedEvent(
"Party", null,
"Europe/Berlin", null, LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:url");
assertThat(html).contains("/events/" + event.getEventToken());
}
@Test
void eventRouteContainsOgImage() throws Exception {
EventJpaEntity event = seedEvent(
"Party", null,
"Europe/Berlin", null, LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:image");
assertThat(html).contains("/og-image.png");
}
@Test
void unknownEventTokenFallsBackToGenericMeta() throws Exception {
String html = mockMvc.perform(
get("/events/" + UUID.randomUUID()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:title");
assertThat(html).contains("content=\"fete\"");
}
// --- HTML escaping ---
@Test
void specialCharactersAreHtmlEscaped() throws Exception {
EventJpaEntity event = seedEvent(
"Tom & Jerry's \"Party\"", "Fun <times> & more",
"Europe/Berlin", "O'Brien's Pub", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("Tom &amp; Jerry");
assertThat(html).contains("&amp; more");
assertThat(html).contains("&lt;times&gt;");
assertThat(html).doesNotContain("content=\"Tom & Jerry");
}
// --- Title truncation ---
@Test
void longTitleIsTruncatedTo70Chars() throws Exception {
String longTitle = "A".repeat(80);
EventJpaEntity event = seedEvent(
longTitle, "Desc",
"Europe/Berlin", null, LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("A".repeat(67) + "...");
assertThat(html).doesNotContain("A".repeat(68));
}
// --- Description formatting ---
@Test
void eventWithoutLocationOmitsPinEmoji() throws Exception {
EventJpaEntity event = seedEvent(
"Online Meetup", "Virtual gathering",
"Europe/Berlin", null, LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).doesNotContain("📍");
}
@Test
void eventWithoutDescriptionOmitsDash() throws Exception {
EventJpaEntity event = seedEvent(
"Silent Event", null,
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("📅");
assertThat(html).contains("Berlin");
assertThat(html).doesNotContain("");
}
private EventJpaEntity seedEvent(
String title, String description, String timezone,
String location, LocalDate expiryDate) {
var entity = new EventJpaEntity();
entity.setEventToken(UUID.randomUUID());
entity.setOrganizerToken(UUID.randomUUID());
entity.setTitle(title);
entity.setDescription(description);
entity.setDateTime(
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
entity.setTimezone(timezone);
entity.setLocation(location);
entity.setExpiryDate(expiryDate);
entity.setCreatedAt(OffsetDateTime.now());
return eventJpaRepository.save(entity);
}
}

View File

@@ -29,8 +29,10 @@ class WebConfigTest {
@Test
void apiPrefixNotAccessibleWithoutIt() throws Exception {
// /events without /api prefix should not resolve to the API endpoint
mockMvc.perform(get("/events"))
.andExpect(status().isNotFound());
// /events without /api prefix should not resolve to the REST API endpoint;
// it is served by SpaController as HTML instead
mockMvc.perform(get("/events")
.accept("text/html"))
.andExpect(status().isOk());
}
}