Adressdaten-Bereich, Mock hinter einem Profil, Webhook

Drei Stränge, die sich über dieselben Dateien ziehen (HomePage, index.css,
README, application.properties) und deshalb nicht getrennt committet werden
können, ohne einen nicht übersetzbaren Zwischenstand zu hinterlassen:

Adressdaten: Der Bereich zeigt den gesamten Bestand des globalen Telefon-
buchs - zuerst aus dem Adress-Cache der App (Protokoll 8), sonst über das
Kommando "addresses" und ersatzweise aus rund 110 Einzelabfragen hinter
einem Wartedialog. Der Mock gibt denselben Bestand heraus und lässt sich
über /api/mock/address-cache leeren, um die Rückfallebene zu prüfen.

Mock hinter dem Profil "mock": Seine Bohnen (/ws und /api/mock/**) hängen
jetzt an @Profile, sind ohne das Profil also nicht vorhanden. Damit kann
der Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das
Container-Image setzt das Profil nicht. Je ein Test hält beide Richtungen
fest.

Webhook: POST /api/webhook nimmt beliebiges JSON eines fremden Systems an
und reicht es über Server-Sent Events an die offenen Browser weiter, wo es
der neue Bereich "Webhook" unverändert anzeigt. Der WebSocket kam dafür
nicht in Frage - er gehört der SwyxTray-App. Das Backend hält die letzten
50 Nachrichten vor und liefert sie beim Wiederverbinden anhand der
Last-Event-ID nach; ein Heartbeat und X-Accel-Buffering: no halten die
Verbindung durch Reverse Proxys hindurch offen. Ein Token (app.webhook.token)
ist vorgesehen, aber nicht voreingestellt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
SvenandClaude Opus 5 committed 2026-08-24 15:11:55 +02:00
1 parent b2f608055a
commit 01594a1f3d
31 files changed
+2687 -32

No files matched your search

@@ -1,13 +1,32 @@
package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import de.appcreation.swyxweb.web.MockController;
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;
@SpringBootTest
class BackendApplicationTests {
@Autowired
ApplicationContext context;
@Test
void contextLoads() {
}
/**
* Ohne das Profil „mock" – also so, wie die Anwendung im Container läuft –
* darf der SwyxTray-Mock nicht im Kontext stehen.
*/
@Test
void mockIsAbsentWithoutItsProfile() {
assertThat(context.getBeansOfType(SwyxTrayMockHandler.class)).isEmpty();
assertThat(context.getBeansOfType(MockController.class)).isEmpty();
}
}
@@ -0,0 +1,30 @@
package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import de.appcreation.swyxweb.web.MockController;
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;
import org.springframework.test.context.ActiveProfiles;
/**
* Gegenstück zu {@link BackendApplicationTests#mockIsAbsentWithoutItsProfile()}:
* mit dem Profil „mock" muss der SwyxTray-Mock vollständig da sein.
*/
@SpringBootTest
@ActiveProfiles("mock")
class MockProfileTests {
@Autowired
ApplicationContext context;
@Test
void mockIsPresentWithItsProfile() {
assertThat(context.getBeansOfType(SwyxTrayMockHandler.class)).isNotEmpty();
assertThat(context.getBeansOfType(MockController.class)).isNotEmpty();
}
}
@@ -0,0 +1,134 @@
package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import de.appcreation.swyxweb.webhook.WebhookService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
/**
* Der Webhook nimmt beliebiges JSON an und hält es im Verlauf vor. Ohne
* gesetztes Token ist er offen – so läuft er ohne Zutun des aufrufenden Systems.
*/
@SpringBootTest
@AutoConfigureMockMvc
class WebhookControllerTests {
@Autowired
MockMvc mvc;
@Autowired
WebhookService service;
@BeforeEach
void emptyHistory() {
service.clear();
}
@Test
void acceptsJsonAndReturnsReceipt() throws Exception {
mvc.perform(post("/api/webhook")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Muster GmbH\",\"number\":\"+493012345\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").isNumber())
.andExpect(jsonPath("$.receivedAt").exists());
assertThat(service.history()).hasSize(1);
}
/** Kein festes Schema: Auch eine Liste ist gültige Nutzlast. */
@Test
void acceptsAnyJsonShape() throws Exception {
mvc.perform(post("/api/webhook")
.contentType(MediaType.APPLICATION_JSON)
.content("[{\"name\":\"Abt, Bettina\",\"number\":\"7587\"}]"))
.andExpect(status().isOk());
assertThat(service.history()).hasSize(1);
assertThat(service.history().getFirst().payload().isArray()).isTrue();
}
@Test
void historyKeepsOrderAndCanBeCleared() throws Exception {
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{\"n\":1}"))
.andExpect(status().isOk());
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{\"n\":2}"))
.andExpect(status().isOk());
mvc.perform(get("/api/webhook/history"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[0].payload.n").value(1))
.andExpect(jsonPath("$[1].payload.n").value(2));
mvc.perform(delete("/api/webhook/history"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").value(2));
assertThat(service.history()).isEmpty();
}
/** Ohne Token in der Konfiguration darf der Kopf fehlen. */
@Test
void tokenIsNotRequiredWhenUnset() throws Exception {
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isOk());
}
@Test
void rejectsPayloadAboveTheLimit() throws Exception {
// max-size steht auf 256 KiB; ein Wert deutlich darüber muss abgelehnt werden.
String big = "{\"v\":\"" + "x".repeat(300_000) + "\"}";
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content(big))
.andExpect(status().isPayloadTooLarge());
assertThat(service.history()).isEmpty();
}
@Nested
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = "app.webhook.token=geheim")
class WithToken {
@Autowired
MockMvc mvc;
@Test
void rejectsCallWithoutToken() throws Exception {
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isUnauthorized());
}
@Test
void rejectsWrongToken() throws Exception {
mvc.perform(post("/api/webhook")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Webhook-Token", "falsch")
.content("{}"))
.andExpect(status().isUnauthorized());
}
@Test
void acceptsCorrectToken() throws Exception {
mvc.perform(post("/api/webhook")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Webhook-Token", "geheim")
.content("{}"))
.andExpect(status().isOk());
}
}
}