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:
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")
|
||||
|
||||
Reference in new issue
Block a user