MongoDB-Ablage für Adressen, Anrufe und Jobs; Webhook-URLs je Datenart

Adress-, Anruf- und Jobdaten landen dauerhaft in der MongoDB
(ArchiveService, best-effort auf eigenem Thread): Die Startseite meldet
Adress-Cache und Anruf-Ereignisse an POST /api/addresses bzw. /api/calls,
der Webhook legt seine Nutzlasten selbst ab. Adressen tragen eine
generierte Kennung, die Rufnummer erkennt per Upsert die Dublette.

Der Webhook bekommt je Datenart eine eigene URL – /api/webhook/kunden,
/api/webhook/kuriere, /api/webhook/jobs –, die URL bestimmt die Ablage,
ein Kennzeichen in der Nutzlast ist nicht mehr nötig. Die Herkunft steht
als channel am Ereignis. Der generische POST /api/webhook bleibt.

Die Controller-Tests ersetzen den ArchiveService (MockitoBean) und
laufen damit ohne MongoDB. Markenrot laut Styleguide in beiden Farbmodi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
SvenandClaude Fable 5 committed 2026-08-27 10:24:53 +02:00
1 parent 1598852785
commit 97c3778180
21 files changed
+1011 -26

No files matched your search

@@ -0,0 +1,57 @@
package de.appcreation.swyxweb;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
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.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.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import de.appcreation.swyxweb.storage.ArchiveService;
/**
* Der Endpunkt nimmt den Adress-Cache der SwyxTray-App an und bestätigt nur
* die Annahme (202). Der {@link ArchiveService} ist ersetzt – der Test läuft
* ohne MongoDB und hinterlässt dort nichts.
*/
@SpringBootTest
@AutoConfigureMockMvc
class AddressControllerTests {
@Autowired
MockMvc mvc;
@MockitoBean
ArchiveService archive;
@Test
void acceptsSwyxTrayMessageAndReturnsCount() throws Exception {
mvc.perform(post("/api/addresses")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"addresses":[{"name":"Abt, Bettina","number":"7587","description":"S-SB"},
{"name":"Abdul, Rokhsareh","number":"5215","description":"Globales Telefonbuch"}],
"type":"addresses"}"""))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$").value(2));
verify(archive).saveAddresses(any(), eq("swyxtray"), argThat(entries -> entries.size() == 2));
}
@Test
void rejectsJsonWithoutAddresses() throws Exception {
mvc.perform(post("/api/addresses")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"foo\":\"bar\"}"))
.andExpect(status().isBadRequest());
}
}
@@ -0,0 +1,76 @@
package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import tools.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import de.appcreation.swyxweb.storage.AddressEntry;
/**
* {@link AddressEntry#manyFrom} füllt die Klasse aus jeder Form, in der
* Adressdaten hereinkommen: einzelner Webhook-Eintrag (Kunde, Kurier, Job),
* eine Liste solcher Einträge oder die Adress-Nachricht der SwyxTray-App.
*/
class AddressEntryTests {
private final ObjectMapper mapper = new ObjectMapper();
private List<AddressEntry> parse(String json) {
return AddressEntry.manyFrom(mapper.readTree(json));
}
/** Ein Kunde, wie ihn das Fremdsystem über den Webhook schickt. */
@Test
void readsSingleWebhookEntry() {
List<AddressEntry> entries = parse("""
{"name":"SYSGEN GmbH","number":"+49421409660",
"description":"Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164"}""");
assertThat(entries).hasSize(1);
AddressEntry entry = entries.getFirst();
assertThat(entry.name()).isEqualTo("SYSGEN GmbH");
assertThat(entry.number()).isEqualTo("+49421409660");
assertThat(entry.description()).isEqualTo("Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164");
}
/** Kuriere und Jobs kommen analog – auch als Liste. */
@Test
void readsListOfEntries() {
List<AddressEntry> entries = parse("""
[{"name":"Rainer Peters (HH1003)","number":"+49171677xxxx",
"description":"Kurier | NL Hamburg | PKW | cr 2024"},
{"name":"SYSGEN GmbH","number":"+49421409660","description":"Kunde | …"}]""");
assertThat(entries).hasSize(2);
assertThat(entries.getFirst().description()).startsWith("Kurier |");
}
/** Die Adress-Nachricht der SwyxTray-App: verpackt in "addresses", mit "type". */
@Test
void readsSwyxTrayAddressMessage() {
List<AddressEntry> entries = parse("""
{"addresses":[{"name":"Abt, Bettina","number":"7587","description":"S-SB"},
{"name":"Abdul, Rokhsareh","number":"5215","description":"Globales Telefonbuch"}],
"type":"addresses"}""");
assertThat(entries).hasSize(2);
assertThat(entries).extracting(AddressEntry::number).containsExactly("7587", "5215");
}
/**
* Ohne Rufnummer ist es kein Eintrag – sie ist der Schlüssel der Sammlung,
* ohne sie ließe sich eine Dublette nicht erkennen. Anderes JSON ergibt nichts.
*/
@Test
void skipsUnusableJson() {
assertThat(parse("{\"foo\":\"bar\"}")).isEmpty();
assertThat(parse("{\"name\":\"Nur Name GmbH\"}")).isEmpty();
assertThat(parse("{\"addresses\":[{\"description\":\"nur Text\"}]}")).isEmpty();
assertThat(parse("[1,2,3]")).isEmpty();
assertThat(parse("\"nur eine Zeichenkette\"")).isEmpty();
}
}
@@ -0,0 +1,56 @@
package de.appcreation.swyxweb;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.verify;
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.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.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import de.appcreation.swyxweb.storage.ArchiveService;
/**
* Der Endpunkt nimmt die Anruf-Ereignisse der Startseite an und bestätigt nur
* die Annahme (202). Der {@link ArchiveService} ist ersetzt – der Test läuft
* ohne MongoDB und hinterlässt dort nichts.
*/
@SpringBootTest
@AutoConfigureMockMvc
class CallControllerTests {
@Autowired
MockMvc mvc;
@MockitoBean
ArchiveService archive;
@Test
void acceptsEventsAndReturnsCount() throws Exception {
mvc.perform(post("/api/calls")
.contentType(MediaType.APPLICATION_JSON)
.content("""
[{"line":1,"event":"incoming","peer":"Muster GmbH (+493012345)",
"peerNumber":"+493012345","peerName":"Muster GmbH",
"stateText":"klingelt","state":"LSRinging"},
{"line":1,"event":"ended","direction":"incoming"}]"""))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$").value(2));
verify(archive).saveCalls(argThat(calls -> calls.size() == 2));
}
@Test
void rejectsEmptyList() throws Exception {
mvc.perform(post("/api/calls")
.contentType(MediaType.APPLICATION_JSON)
.content("[]"))
.andExpect(status().isBadRequest());
}
}
@@ -0,0 +1,81 @@
package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import de.appcreation.swyxweb.storage.JobEntry;
/**
* {@link JobEntry} wird aus der Webhook-Nutzlast mit {@code "type":"job"}
* gefüllt – samt Kunde, Kurier und Stationen.
*/
class JobEntryTests {
private final ObjectMapper mapper = new ObjectMapper();
private static final String SAMPLE = """
{
"type": "job",
"id": 21891253,
"state": 2,
"ordertime": "2026-07-31T16:30:00+02:00",
"orderdate": "2026-07-31",
"modified": "2026-07-30T18:14:02+02:00",
"vehicle": "Transporter",
"customer": { "csc_id": 868374, "name": "B+M isol nord GmbH", "hq": "Bremen" },
"courier": { "cr_id": 14116, "sid": "B1006", "name": "CA Kurier B1006", "number": "+4917xxxxxxx" },
"tours": [
{ "id": 22396738, "sort": 1, "state": 1, "mode": "", "comp": "B+M isol nord GmbH",
"person": "", "number": "+4942037010xx", "street": "Löwenhof 2", "zip": "28844",
"city": "Weyhe-Dreye", "com": "", "finished": "2026-07-30T18:13:11+02:00" },
{ "id": 22396739, "sort": 2, "state": 1, "mode": "", "comp": "Reichspräsident Ebert Kaserne",
"person": "", "number": null, "street": "Osdorfer Landstr. 365", "zip": "22589",
"city": "Hamburg", "com": "", "finished": "2026-07-30T18:13:51+02:00" }
]
}""";
@Test
void recognizesJobPayload() {
assertThat(JobEntry.isJob(mapper.readTree(SAMPLE))).isTrue();
assertThat(JobEntry.isJob(mapper.readTree("{\"name\":\"SYSGEN GmbH\"}"))).isFalse();
assertThat(JobEntry.isJob(mapper.readTree("{\"type\":\"addresses\",\"addresses\":[]}"))).isFalse();
}
@Test
void fillsAllFieldsFromSample() {
Instant receivedAt = Instant.parse("2026-08-26T10:00:00Z");
JsonNode json = mapper.readTree(SAMPLE);
JobEntry job = JobEntry.from(json, mapper, receivedAt);
assertThat(job.id()).isEqualTo(21891253L);
assertThat(job.receivedAt()).isEqualTo(receivedAt);
assertThat(job.state()).isEqualTo(2);
assertThat(job.ordertime()).isEqualTo("2026-07-31T16:30:00+02:00");
assertThat(job.orderdate()).isEqualTo("2026-07-31");
assertThat(job.vehicle()).isEqualTo("Transporter");
assertThat(job.customer().cscId()).isEqualTo(868374);
assertThat(job.customer().name()).isEqualTo("B+M isol nord GmbH");
assertThat(job.customer().hq()).isEqualTo("Bremen");
assertThat(job.courier().crId()).isEqualTo(14116);
assertThat(job.courier().sid()).isEqualTo("B1006");
assertThat(job.courier().number()).isEqualTo("+4917xxxxxxx");
assertThat(job.tours()).hasSize(2);
JobEntry.Tour first = job.tours().getFirst();
assertThat(first.id()).isEqualTo(22396738L);
assertThat(first.sort()).isEqualTo(1);
assertThat(first.street()).isEqualTo("Löwenhof 2");
assertThat(first.finished()).isEqualTo("2026-07-30T18:13:11+02:00");
// Fehlende Rufnummer der zweiten Station bleibt null.
assertThat(job.tours().get(1).number()).isNull();
}
}
@@ -1,12 +1,18 @@
package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
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.storage.ArchiveService;
import de.appcreation.swyxweb.webhook.WebhookEvent;
import de.appcreation.swyxweb.webhook.WebhookService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
@@ -16,6 +22,7 @@ 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.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
/**
@@ -32,6 +39,10 @@ class WebhookControllerTests {
@Autowired
WebhookService service;
/** Ersetzt: Die Tests laufen ohne MongoDB und hinterlassen dort nichts. */
@MockitoBean
ArchiveService archive;
@BeforeEach
void emptyHistory() {
service.clear();
@@ -81,6 +92,49 @@ class WebhookControllerTests {
assertThat(service.history()).isEmpty();
}
/**
* Je Datenart eine eigene URL: Kunden und Kuriere werden als Adressdaten
* abgelegt, Jobs als Jobs – die Herkunft steht als {@code channel} am
* Ereignis und braucht kein Kennzeichen in der Nutzlast.
*/
@Test
void dedicatedUrlsRecordTheirChannel() throws Exception {
mvc.perform(post("/api/webhook/kunden")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"SYSGEN GmbH\",\"number\":\"+49421409660\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").isNumber());
mvc.perform(post("/api/webhook/kuriere")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Rainer Peters (HH1003)\",\"number\":\"+49171677xxxx\"}"))
.andExpect(status().isOk());
mvc.perform(post("/api/webhook/jobs")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"id\":4711,\"state\":1,\"customer\":{\"csc_id\":100164,\"name\":\"SYSGEN GmbH\"}}"))
.andExpect(status().isOk());
assertThat(service.history())
.extracting(WebhookEvent::channel)
.containsExactly("kunden", "kuriere", "jobs");
// Kunden und Kuriere landen als Adressdaten in der Ablage, der Job als Job.
verify(archive).saveAddresses(any(), eq("kunden"), argThat(entries -> entries.size() == 1));
verify(archive).saveAddresses(any(), eq("kuriere"), argThat(entries -> entries.size() == 1));
verify(archive).saveJob(argThat(job -> job.id() == 4711));
}
/** Der generische Webhook trägt keine Herkunft – die Nutzlast entscheidet. */
@Test
void genericWebhookHasNoChannel() throws Exception {
mvc.perform(post("/api/webhook")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Muster GmbH\",\"number\":\"+493012345\"}"))
.andExpect(status().isOk());
assertThat(service.history().getFirst().channel()).isNull();
verify(archive).saveAddresses(any(), eq("webhook"), argThat(entries -> entries.size() == 1));
}
/** Ohne Token in der Konfiguration darf der Kopf fehlen. */
@Test
void tokenIsNotRequiredWhenUnset() throws Exception {
@@ -93,7 +147,7 @@ class WebhookControllerTests {
// 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());
.andExpect(status().isContentTooLarge());
assertThat(service.history()).isEmpty();
}
@@ -113,6 +167,17 @@ class WebhookControllerTests {
.andExpect(status().isUnauthorized());
}
/** Das Token gilt für alle Webhook-URLs gleichermaßen. */
@Test
void rejectsDedicatedUrlWithoutToken() throws Exception {
mvc.perform(post("/api/webhook/kunden").contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isUnauthorized());
mvc.perform(post("/api/webhook/kuriere").contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isUnauthorized());
mvc.perform(post("/api/webhook/jobs").contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isUnauthorized());
}
@Test
void rejectsWrongToken() throws Exception {
mvc.perform(post("/api/webhook")