Add Open Graph and Twitter Card meta-tags for link previews
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:
188
backend/src/main/java/de/fete/adapter/in/web/SpaController.java
Normal file
188
backend/src/main/java/de/fete/adapter/in/web/SpaController.java
Normal file
@@ -0,0 +1,188 @@
|
||||
package de.fete.adapter.in.web;
|
||||
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.port.in.GetEventUseCase;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||
|
||||
/** Serves the SPA index.html with injected Open Graph and Twitter Card meta-tags. */
|
||||
@Controller
|
||||
public class SpaController {
|
||||
|
||||
private static final String PLACEHOLDER = "<!-- OG_META_TAGS -->";
|
||||
private static final int MAX_TITLE_LENGTH = 70;
|
||||
private static final int MAX_DESCRIPTION_LENGTH = 200;
|
||||
private static final String GENERIC_TITLE = "fete";
|
||||
private static final String GENERIC_DESCRIPTION =
|
||||
"Privacy-focused event planning. Create and share events without accounts.";
|
||||
private static final DateTimeFormatter DATE_FORMAT =
|
||||
DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy 'at' h:mm a", Locale.ENGLISH);
|
||||
|
||||
private final GetEventUseCase getEventUseCase;
|
||||
private String htmlTemplate;
|
||||
|
||||
/** Creates a new SpaController. */
|
||||
public SpaController(GetEventUseCase getEventUseCase) {
|
||||
this.getEventUseCase = getEventUseCase;
|
||||
}
|
||||
|
||||
/** Loads and caches the index.html template at startup. */
|
||||
@PostConstruct
|
||||
void loadTemplate() throws IOException {
|
||||
var resource = new ClassPathResource("/static/index.html");
|
||||
if (resource.exists()) {
|
||||
htmlTemplate = resource.getContentAsString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
/** Serves SPA HTML with generic meta-tags for non-event routes. */
|
||||
@GetMapping(
|
||||
value = {"/", "/create", "/events"},
|
||||
produces = MediaType.TEXT_HTML_VALUE
|
||||
)
|
||||
@ResponseBody
|
||||
public String serveGenericPage(HttpServletRequest request) {
|
||||
if (htmlTemplate == null) {
|
||||
return "";
|
||||
}
|
||||
String baseUrl = getBaseUrl(request);
|
||||
return htmlTemplate.replace(PLACEHOLDER, renderTags(buildGenericMeta(baseUrl)));
|
||||
}
|
||||
|
||||
/** Serves SPA HTML with event-specific meta-tags. */
|
||||
@GetMapping(
|
||||
value = "/events/{token}",
|
||||
produces = MediaType.TEXT_HTML_VALUE
|
||||
)
|
||||
@ResponseBody
|
||||
public String serveEventPage(@PathVariable String token,
|
||||
HttpServletRequest request) {
|
||||
if (htmlTemplate == null) {
|
||||
return "";
|
||||
}
|
||||
String baseUrl = getBaseUrl(request);
|
||||
Map<String, String> meta = resolveEventMeta(token, baseUrl);
|
||||
return htmlTemplate.replace(PLACEHOLDER, renderTags(meta));
|
||||
}
|
||||
|
||||
// --- Meta-tag composition ---
|
||||
|
||||
private Map<String, String> buildEventMeta(Event event, String baseUrl) {
|
||||
var tags = new LinkedHashMap<String, String>();
|
||||
String title = truncateTitle(event.getTitle());
|
||||
String description = formatDescription(event);
|
||||
tags.put("og:title", title);
|
||||
tags.put("og:description", description);
|
||||
tags.put("og:url", baseUrl + "/events/" + event.getEventToken().value());
|
||||
tags.put("og:type", "website");
|
||||
tags.put("og:site_name", GENERIC_TITLE);
|
||||
tags.put("og:image", baseUrl + "/og-image.png");
|
||||
tags.put("twitter:card", "summary");
|
||||
tags.put("twitter:title", title);
|
||||
tags.put("twitter:description", description);
|
||||
return tags;
|
||||
}
|
||||
|
||||
private Map<String, String> buildGenericMeta(String baseUrl) {
|
||||
var tags = new LinkedHashMap<String, String>();
|
||||
tags.put("og:title", GENERIC_TITLE);
|
||||
tags.put("og:description", GENERIC_DESCRIPTION);
|
||||
tags.put("og:url", baseUrl);
|
||||
tags.put("og:type", "website");
|
||||
tags.put("og:site_name", GENERIC_TITLE);
|
||||
tags.put("og:image", baseUrl + "/og-image.png");
|
||||
tags.put("twitter:card", "summary");
|
||||
tags.put("twitter:title", GENERIC_TITLE);
|
||||
tags.put("twitter:description", GENERIC_DESCRIPTION);
|
||||
return tags;
|
||||
}
|
||||
|
||||
private Map<String, String> resolveEventMeta(String token, String baseUrl) {
|
||||
try {
|
||||
UUID uuid = UUID.fromString(token);
|
||||
Optional<Event> event =
|
||||
getEventUseCase.getByEventToken(new EventToken(uuid));
|
||||
if (event.isPresent()) {
|
||||
return buildEventMeta(event.get(), baseUrl);
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Invalid UUID — fall back to generic
|
||||
}
|
||||
return buildGenericMeta(baseUrl);
|
||||
}
|
||||
|
||||
// --- Description formatting ---
|
||||
|
||||
private String truncateTitle(String title) {
|
||||
if (title.length() <= MAX_TITLE_LENGTH) {
|
||||
return title;
|
||||
}
|
||||
return title.substring(0, MAX_TITLE_LENGTH - 3) + "...";
|
||||
}
|
||||
|
||||
private String formatDescription(Event event) {
|
||||
ZonedDateTime zoned = event.getDateTime().atZoneSameInstant(event.getTimezone());
|
||||
var sb = new StringBuilder();
|
||||
sb.append("📅 ").append(zoned.format(DATE_FORMAT));
|
||||
|
||||
if (event.getLocation() != null && !event.getLocation().isBlank()) {
|
||||
sb.append(" · 📍 ").append(event.getLocation());
|
||||
}
|
||||
|
||||
if (event.getDescription() != null && !event.getDescription().isBlank()) {
|
||||
sb.append(" — ").append(event.getDescription());
|
||||
}
|
||||
|
||||
String result = sb.toString();
|
||||
if (result.length() > MAX_DESCRIPTION_LENGTH) {
|
||||
return result.substring(0, MAX_DESCRIPTION_LENGTH - 3) + "...";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- HTML rendering ---
|
||||
|
||||
private String renderTags(Map<String, String> tags) {
|
||||
var sb = new StringBuilder();
|
||||
for (var entry : tags.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
String value = escapeHtml(entry.getValue());
|
||||
String attr = key.startsWith("twitter:") ? "name" : "property";
|
||||
sb.append("<meta ").append(attr).append("=\"").append(key)
|
||||
.append("\" content=\"").append(value).append("\">\n");
|
||||
}
|
||||
return sb.toString().stripTrailing();
|
||||
}
|
||||
|
||||
private String escapeHtml(String input) {
|
||||
return input
|
||||
.replace("&", "&")
|
||||
.replace("\"", """)
|
||||
.replace("<", "<")
|
||||
.replace(">", ">");
|
||||
}
|
||||
|
||||
private String getBaseUrl(HttpServletRequest request) {
|
||||
return ServletUriComponentsBuilder.fromRequestUri(request)
|
||||
.replacePath("")
|
||||
.build()
|
||||
.toUriString();
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,17 @@
|
||||
package de.fete.config;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
|
||||
/** Configures API path prefix and SPA static resource serving. */
|
||||
/** Configures API path prefix. Static resources served by default Spring Boot handler. */
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
/** Provides a system clock bean for time-dependent services. */
|
||||
@Bean
|
||||
Clock clock() {
|
||||
return Clock.systemDefaultZone();
|
||||
@@ -25,23 +21,4 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
public void configurePathMatch(PathMatchConfigurer configurer) {
|
||||
configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.resourceChain(true)
|
||||
.addResolver(new PathResourceResolver() {
|
||||
@Override
|
||||
protected Resource getResource(String resourcePath,
|
||||
Resource location) throws IOException {
|
||||
Resource requested = location.createRelative(resourcePath);
|
||||
if (requested.exists() && requested.isReadable()) {
|
||||
return requested;
|
||||
}
|
||||
Resource index = new ClassPathResource("/static/index.html");
|
||||
return (index.exists() && index.isReadable()) ? index : null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ spring.jpa.open-in-view=false
|
||||
# Liquibase
|
||||
spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml
|
||||
|
||||
# Proxy headers
|
||||
server.forward-headers-strategy=framework
|
||||
|
||||
# Actuator
|
||||
management.endpoints.web.exposure.include=health
|
||||
management.endpoint.health.show-details=never
|
||||
|
||||
@@ -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 & Jerry");
|
||||
assertThat(html).contains("& more");
|
||||
assertThat(html).contains("<times>");
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
13
backend/src/test/resources/static/index.html
Normal file
13
backend/src/test/resources/static/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<!-- OG_META_TAGS -->
|
||||
<title>fete</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user