Erweitere VoTiAn NG um Auftrags-, Stamm- und Verwaltungsfunktionen

Ergänzt Auftragserfassung und -bearbeitung, Unternehmerbelege, Stornos, Lagerkorrekturen, Stammdaten, Abrechnung, CRM, Formulare, Berichte und Preisverwaltung auf Basis der aktuellen PHP-Anwendung.

Enthält Integrations- und Oberflächentests sowie dokumentierte Abgleiche und Prüfstände.
This commit is contained in:
Sven committed 2026-10-13 20:31:31 +02:00
1 parent 42c87282ee
commit 363bc27c26
190 files changed
+14815 -1857

No files matched your search

@@ -0,0 +1,18 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.AddressAdministration.*;
import static de.stadtbote.votian.OrderEntryService.fields;
class AddressAdministrationTest {
final DatabaseService db=mock(DatabaseService.class);final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);final AddressAdministration service=new AddressAdministration(db,new ObjectMapper(),catalog);
@Test void rightsApplyToEveryEndpoint(){var u=AppointmentServiceTest.user(0);assertThatThrownBy(()->service.options(u,Kind.POSTAL)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.query(u,Kind.AREA,null)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.create(u,Kind.POSTAL,null)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.remove(u,Kind.POSTAL,null)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.resume(u,Kind.POSTAL,"bad")).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);verifyNoInteractions(db);}
@Test void validatesRangesAndEscapesSearchWildcards(){assertThat(house("")).isNull();assertThat(house("0012")).isEqualTo(12);assertThatThrownBy(()->house("12a")).hasMessageContaining("Hausnummer");assertThat(like("A_%!B")).isEqualTo("A!_!%!!B%");var s=new Save(UUID.randomUUID().toString(),"10000","Straße","10000","Ort","DE","20","10",0);assertThatThrownBy(()->service.create(AppointmentServiceTest.user(2),Kind.POSTAL,s)).hasMessageContaining("größer");verifyNoInteractions(db);}
@Test void rangeAndParityChooseSpecificPostalMapping(){var station=OrderEntryFixtures.request().stops().getFirst();when(db.queryForList(contains("FROM serviceplzaddress m"),any(Object[].class))).thenReturn(List.of(fields("id",1,"targetId",101,"houseFrom",null,"houseTo",null,"mode",0),fields("id",2,"targetId",102,"houseFrom",0,"houseTo","10","mode",2),fields("id",3,"targetId",103,"houseFrom",1,"houseTo","3","mode",1)));assertThat(LegacyAddressTariffs.postal(db,7,station)).isEqualTo(103);verify(db).queryForList(contains("ESCAPE '!'"),eq(7L),eq("20095"),eq("DE"),eq("Teststraße%"));}
@Test void areaHonorsExactHouseSuffixOverride(){var original=OrderEntryFixtures.request().stops().getFirst();var tree=new ObjectMapper().valueToTree(original);((com.fasterxml.jackson.databind.node.ObjectNode)tree).put("houseNumber","1 a");var station=new ObjectMapper().convertValue(tree,OrderEntryModels.Station.class);when(db.queryForList(contains("FROM serviceplzareaaddress m"),any(Object[].class))).thenReturn(List.of(fields("id",5,"targetId",201,"houseFrom",null,"houseTo","9","mode",1)));when(catalog.parameter(0,"MASK_AREA_ID_FROM_ADDRESS_5")).thenReturn("1a,202;1b,203");when(db.queryForList(startsWith("SELECT srvpa_id FROM serviceplzarea"),eq(202L),eq(7L))).thenReturn(List.of(Map.of("srvpa_id",202)));assertThat(LegacyAddressTariffs.area(db,catalog,7,101,station)).isEqualTo(202);}
@Test void zeroUpperBoundIsNotAnUnlimitedRange(){assertThat(LegacyAddressTariffs.bound(Map.of("to","0"),"to",99999)).isZero();assertThat(LegacyAddressTariffs.bound(Map.of("to",""),"to",99999)).isEqualTo(99999);}
}
@@ -0,0 +1,27 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.AddressAdministration.*;
public class AddressIntegrationAssertions {
public static void verify(DatabaseService db){
var json=new ObjectMapper();var user=AppointmentServiceTest.user(2);var service=new AddressAdministration(db,json,new OrderEntryCatalog(db));
var query=new Query("","","","","","","",null,"street",0);
var save=new Save(UUID.randomUUID().toString(),"10000","Musterstraße","20095","Hamburg","DE","1","20",1);
assertThat(service.create(user,Kind.POSTAL,save)).containsEntry("state","DONE");assertThat(service.create(user,Kind.POSTAL,save)).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzaddress",Integer.class)).isEqualTo(1);
assertThat(service.query(user,Kind.POSTAL,query)).containsEntry("total",1L);
var row=db.queryForList(service.select(Kind.POSTAL),7).getFirst();assertThat(row).containsEntry("target","10000").containsEntry("street","Musterstraße");
assertThatThrownBy(()->service.remove(user,Kind.POSTAL,new Delete(UUID.randomUUID().toString(),List.of(new Selection(LegacyMapping.id(row,"id"),"stale"))))).hasMessageContaining("409");
var wrong=new LegacyUser(user.userId(),user.employeeId(),8,user.username(),user.rights());assertThat(service.query(wrong,Kind.POSTAL,query)).containsEntry("total",0L);assertThatThrownBy(()->service.remove(wrong,Kind.POSTAL,new Delete(UUID.randomUUID().toString(),List.of(new Selection(LegacyMapping.id(row,"id"),AdminSupport.revision(row)))))).hasMessageContaining("404");
var failing=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("commit status unavailable")).when(failing).update(startsWith("UPDATE genericdatacontainer SET gdc_context='DONE'"),any(Object[].class));var interrupted=new AddressAdministration(failing,json,new OrderEntryCatalog(failing));
var area=new Save(UUID.randomUUID().toString(),"42","Neue Straße","20095","Hamburg","DE","","",2);
assertThat(interrupted.create(user,Kind.AREA,area)).containsEntry("state","PENDING");assertThat(db.queryForObject("SELECT COUNT(*) FROM address WHERE ad_street='Neue Straße'",Integer.class)).isEqualTo(1);assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzareaaddress",Integer.class)).isEqualTo(1);
assertThat(service.resume(user,Kind.AREA,area.requestId())).containsEntry("state","DONE");assertThat(service.create(user,Kind.AREA,area)).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzareaaddress",Integer.class)).isEqualTo(1);
var first=db.queryForList(service.select(Kind.AREA),7).getFirst();assertThat(first).containsEntry("target","0042");assertThat(service.exceptions(user,LegacyMapping.id(first,"id"))).containsExactly(Map.of("house","1a","area","0043"));
var remove=new Delete(UUID.randomUUID().toString(),List.of(new Selection(LegacyMapping.id(first,"id"),AdminSupport.revision(first))));assertThat(interrupted.remove(user,Kind.AREA,remove)).containsEntry("state","PENDING");assertThat(service.resume(user,Kind.AREA,remove.requestId())).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzareaaddress",Integer.class)).isZero();assertThat(db.queryForObject("SELECT COUNT(*) FROM address WHERE ad_street='Neue Straße'",Integer.class)).isEqualTo(1);
assertThat(service.query(user,Kind.POSTAL,new Query("NOPE","","","","","","",null,"street",0))).containsEntry("total",0L);
}
}
@@ -64,6 +64,87 @@ class ApiSecurityTest {
return (MockHttpSession) login(csrf(null),username,password,200).getRequest().getSession(false);
}
@Test void crmAndMailMutationsRequireSessionAndCsrf() throws Exception {
mvc.perform(get("/api/crm/options")).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
for(String path:List.of("/api/crm/contacts","/api/crm/targets","/api/appointments/44/notifications/preview","/api/appointments/notifications/test/send"))
mvc.perform(post(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
verify(database,never()).writeTransaction(any());verify(database,never()).contactWriteTransaction(any());
}
@Test void messagesAndFormMutationsRequireSessionAndCsrf() throws Exception {
for(String path:List.of("/api/messages/options","/api/forms","/api/newsletters/options","/api/newsletters/mail/options","/api/evaluations/options"))
mvc.perform(get(path)).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
for(String path:List.of("/api/messages/bulletins","/api/messages/preview","/api/messages/send/token","/api/messages/groups","/api/forms/categories","/api/forms/fields","/api/forms/templates","/api/newsletters","/api/newsletters/images","/api/newsletters/12/mail/preview","/api/newsletters/mail/token/send","/api/evaluations","/api/evaluations/12/mail/preview","/api/evaluations/mail/token/send"))
mvc.perform(post(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
mvc.perform(put("/api/forms/categories/10/fields").session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
var token=csrf(session);
mvc.perform(get("/api/forms").session(session)).andExpect(status().isForbidden());
mvc.perform(get("/api/newsletters/options").session(session)).andExpect(status().isForbidden());
mvc.perform(post("/api/messages/bulletins").session(session).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
verify(database,never()).writeTransaction(any());
}
@Test void zoneTariffsAndManagementRequireSessionCsrfAndAdministrationRight() throws Exception {
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("existing-user"),eq("existing-password"),eq("existing-password"))).thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","existing-user","emp_rights","1")));
mvc.perform(get("/api/administration/zone-prices")).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
for(String path:List.of("/api/administration/zone-prices","/api/administration/zone-prices/management"))
mvc.perform(put(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
var token=csrf(session);
mvc.perform(put("/api/administration/zone-prices/management").session(session).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
verify(database,never()).writeTransaction(any());
}
@Test void tariffMutationsRequireSessionCsrfAndGeneralPriceRight() throws Exception {
mvc.perform(get("/api/administration/service-prices")).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
mvc.perform(put("/api/administration/service-prices").session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
mvc.perform(put("/api/administration/service-prices/definitions").session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
var token=csrf(session);
mvc.perform(post("/api/administration/service-prices/query").session(session).header(token.header(),token.token()).contentType("application/json").content("{\"mode\":0,\"customer\":0,\"date\":\"2026-10-13\"}")).andExpect(status().isForbidden());
verify(database,never()).writeTransaction(any());
}
@Test void filterAdministrationRequiresSessionCsrfAndManageRight() throws Exception {
mvc.perform(get("/api/administration/filters")).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
mvc.perform(get("/api/administration/filters").session(session)).andExpect(status().isOk()).andExpect(jsonPath("$.manage").value(false));
mvc.perform(post("/api/administration/filters").session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
var token=csrf(session);
mvc.perform(post("/api/administration/filters").session(session).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
mvc.perform(delete("/api/administration/filters/1").session(session).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
mvc.perform(post("/api/administration/filters/operations/00000000-0000-0000-0000-000000000000/resume").session(session).header(token.header(),token.token())).andExpect(status().isForbidden());
verify(database,never()).writeTransaction(any());
}
@Test void addressAdministrationRequiresSessionCsrfAndHeadquartersRight() throws Exception {
mvc.perform(get("/api/administration/addresses/POSTAL")).andExpect(status().isUnauthorized());
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("existing-user"),eq("existing-password"),eq("existing-password")))
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","existing-user","emp_rights","1")));
var session=authenticated("existing-user","existing-password");var token=csrf(session);
mvc.perform(get("/api/administration/addresses/POSTAL").session(session)).andExpect(status().isForbidden());
for(String path:List.of("/api/administration/addresses/POSTAL","/api/administration/addresses/AREA/remove","/api/administration/addresses/AREA/query","/api/administration/addresses/AREA/operations/00000000-0000-0000-0000-000000000000/resume")) {
mvc.perform(post(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
mvc.perform(post(path).session(session).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
}
verify(database,never()).writeTransaction(any());
}
@Test void businessWorkflowsRequireSessionCsrfAndDomainRights() throws Exception {
for(String path:List.of("/api/master-data/customers/1","/api/orders/1/workbench","/api/orders/1/cancellation","/api/orders/1/articles","/api/orders/1/calculation","/api/billing/options"))mvc.perform(get(path)).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
for(String path:List.of("/api/orders/1/workbench","/api/orders/1/price","/api/master-data/customers/1"))mvc.perform(put(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
for(String path:List.of("/api/orders/1/actions","/api/orders/1/cancellation","/api/orders/1/cancellation/preview","/api/orders/1/articles/movement","/api/orders/1/articles/movement/preview","/api/billing/assign","/api/billing/operations/test/resume"))mvc.perform(post(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("none"),eq("password"),eq("password"))).thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","none","emp_rights","0000000000")));
var restricted=authenticated("none","password");var token=csrf(restricted);
mvc.perform(put("/api/master-data/customers/1").session(restricted).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
mvc.perform(post("/api/orders/1/actions").session(restricted).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
mvc.perform(post("/api/billing/assign").session(restricted).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
verify(database,never()).writeTransaction(any());
}
@Test void healthAndSystemArePublicButDataRequiresSession() throws Exception {
mvc.perform(get("/api/health").with(httpBasic("proxy-fixture","proxy-password")))
.andExpect(status().isOk()).andExpect(jsonPath("$.status").value("UP"));
@@ -80,6 +161,20 @@ class ApiSecurityTest {
.andExpect(status().isUnauthorized());
}
@Test void reportsRequireSessionCsrfAndTheirSeparateLegacyRight() throws Exception {
mvc.perform(get("/api/administration/reports/CUSTOMER/options")).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
mvc.perform(get("/api/administration").session(session)).andExpect(status().isOk())
.andExpect(jsonPath("$.customerReports").value(false)).andExpect(jsonPath("$.courierReports").value(false));
mvc.perform(get("/api/administration/reports/CUSTOMER/options").session(session)).andExpect(status().isForbidden());
mvc.perform(post("/api/administration/reports/CUSTOMER").session(session).contentType("application/json").content("{}"))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
var token=csrf(session);
mvc.perform(post("/api/administration/reports/CUSTOMER").session(session).header(token.header(),token.token()).contentType("application/json").content("{}"))
.andExpect(status().isForbidden());
verify(database,never()).insert(anyString(),anyMap());
}
@Test void existingLoginPersistsSessionRotatesIdAndStoresNoPassword() throws Exception {
Csrf anonymous = csrf(null);
String previousId = anonymous.session().getId();
@@ -94,7 +189,7 @@ class ApiSecurityTest {
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk())
.andExpect(jsonPath("$.headquartersId").value(7)).andExpect(jsonPath("$.roles[0]").value("VIEWER"))
.andExpect(jsonPath("$.password").doesNotExist()).andExpect(jsonPath("$.capabilities.exportOrders").value(true))
.andExpect(jsonPath("$.capabilities.writeOrders").value(false));
.andExpect(jsonPath("$.capabilities.writeOrders").value(true));
mvc.perform(get("/api/me").session(session).with(httpBasic("proxy-fixture","proxy-password")))
.andExpect(status().isOk()).andExpect(jsonPath("$.username").value("existing-user"));
verify(database,times(1)).queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),any(),any(),any());
@@ -148,7 +243,7 @@ class ApiSecurityTest {
verify(database,never()).queryForList(anyString(),any(),any(),any());
}
@Test void noWriteEndpointCanModifyTheDatabase() throws Exception {
@Test void unsupportedMutationsRemainDisabled() throws Exception {
MockHttpSession session = authenticated("existing-user","existing-password");
Csrf csrf = csrf(session);
mvc.perform(post("/api/orders/12/cancel").session(session).header(csrf.header(),csrf.token())
@@ -157,6 +252,46 @@ class ApiSecurityTest {
verify(database,never()).update(anyString(),any(Object[].class));
}
@Test void orderEntryHasItsOwnRightAndStillRequiresCsrf() throws Exception {
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("entry"),eq("password"),eq("password")))
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","entry","emp_rights","0000000100")));
var session=authenticated("entry","password");
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk())
.andExpect(jsonPath("$.capabilities.createOrders").value(true)).andExpect(jsonPath("$.capabilities.readCustomers").value(false))
.andExpect(jsonPath("$.capabilities.writeOrders").value(false));
mvc.perform(get("/api/orders/entry/options").session(session)).andExpect(status().isOk());
mvc.perform(get("/api/orders/entry/cost-centers").session(session)).andExpect(status().isOk());
mvc.perform(get("/api/orders/entry/vehicles").session(session)).andExpect(status().isOk());
mvc.perform(get("/api/customers").session(session)).andExpect(status().isForbidden());
for(String endpoint:List.of("/api/orders","/api/orders/quote"))
mvc.perform(post(endpoint).session(session).contentType("application/json").content(json.writeValueAsString(OrderEntryFixtures.request())))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
verify(database,never()).writeTransaction(any());
}
@Test void orderEntryRejectsUnknownServerOwnedFieldsAndInvalidCommands() throws Exception {
var csrf=csrf(authenticated("existing-user","existing-password"));
var input=json.valueToTree(OrderEntryFixtures.request());
((com.fasterxml.jackson.databind.node.ObjectNode)input).put("hq_id",999);
mvc.perform(post("/api/orders").session(csrf.session()).header(csrf.header(),csrf.token())
.contentType("application/json").content(json.writeValueAsString(input))).andExpect(status().isBadRequest());
mvc.perform(post("/api/orders").session(csrf.session()).header(csrf.header(),csrf.token())
.contentType("application/json").content("{}")).andExpect(status().isBadRequest());
verify(database,never()).writeTransaction(any());
}
@Test void orderEntryIsForbiddenWithoutRightSeven() throws Exception {
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("viewer"),eq("password"),eq("password")))
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","viewer","emp_rights","1111111011")));
var csrf=csrf(authenticated("viewer","password"));
mvc.perform(get("/api/orders/entry/options").session(csrf.session())).andExpect(status().isForbidden());
for(String endpoint:List.of("/api/orders","/api/orders/quote"))
mvc.perform(post(endpoint).session(csrf.session()).header(csrf.header(),csrf.token())
.contentType("application/json").content(json.writeValueAsString(OrderEntryFixtures.request())))
.andExpect(status().isForbidden());
verify(database,never()).writeTransaction(any());
}
@Test void backendDatabaseFailureIs503NotFakeLoginOrEmptyDataset() throws Exception {
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("offline"),eq("password"),eq("password")))
.thenThrow(new DataAccessResourceFailureException("server secret must not be exposed"));
@@ -238,4 +373,48 @@ class ApiSecurityTest {
.andExpect(jsonPath("$.reason").value("AUTHENTICATION_FAILED"))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("Hostfreigabe")));
}
@Test void inventoryAndMapEndpointsRequireEntryRightsAndStockRights() throws Exception {
mvc.perform(get("/api/orders/entry/articles")).andExpect(status().isUnauthorized());
mvc.perform(get("/api/orders/entry/features")).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
mvc.perform(get("/api/orders/entry/features").session(session)).andExpect(status().isOk()).andExpect(jsonPath("$.bookStock").value(false));
mvc.perform(get("/api/orders/entry/stocks").session(session).param("payerId","41").param("articleId","3")).andExpect(status().isForbidden());
mvc.perform(post("/api/orders/entry/map/address").session(session).contentType("application/json").content("{\"latitude\":53,\"longitude\":9}")).andExpect(status().isForbidden());
var csrf=csrf(session);
mvc.perform(post("/api/orders/entry/map/address").session(session).header(csrf.header(),csrf.token()).contentType("application/json").content("{\"latitude\":91,\"longitude\":9}")).andExpect(status().isBadRequest());
}
@Test void administrationAndStatisticsRequireSessionRightsAndCsrf() throws Exception {
mvc.perform(get("/api/administration")).andExpect(status().isUnauthorized());
mvc.perform(get("/api/administration/holidays/2026")).andExpect(status().isUnauthorized());
mvc.perform(get("/api/statistics/options")).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
mvc.perform(get("/api/administration").session(session)).andExpect(status().isOk())
.andExpect(jsonPath("$.holidays").value(true)).andExpect(jsonPath("$.groups").value(false));
mvc.perform(put("/api/administration/holidays/2026").session(session)
.contentType("application/json").content("{}"))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
var token=csrf(session);
mvc.perform(post("/api/administration/groups").session(session).header(token.header(),token.token())
.contentType("application/json").content("{\"name\":\"x\",\"shared\":false}"))
.andExpect(status().isForbidden());
mvc.perform(get("/api/administration/travel-times").session(session).param("mode","unknown").param("from","1").param("to","2"))
.andExpect(status().isBadRequest());
verify(database,never()).update(anyString(),any(Object[].class));
}
@Test void appointmentsRequireSessionSalesRightsAndCsrf() throws Exception {
mvc.perform(get("/api/appointments/options")).andExpect(status().isUnauthorized());
var session=authenticated("existing-user","existing-password");
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk()).andExpect(jsonPath("$.capabilities.readAppointments").value(true));
for(String endpoint:List.of("/api/appointments","/api/appointments/query","/api/appointments/44/confirm","/api/appointments/44/finish","/api/appointments/44/resubmit"))
mvc.perform(post(endpoint).session(session).contentType("application/json").content("{}"))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("sales-disabled"),eq("password"),eq("password")))
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","sales-disabled","emp_rights","100000000000")));
var restricted=authenticated("sales-disabled","password");
mvc.perform(get("/api/appointments/options").session(restricted)).andExpect(status().isForbidden());
verify(database,never()).writeTransaction(any());
}
}
@@ -0,0 +1,28 @@
package de.stadtbote.votian;
import org.junit.jupiter.api.Test;
import java.net.*;import java.io.*;import java.nio.charset.StandardCharsets;import java.util.*;import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
class AppointmentMailTransportTest {
@Test void deliversUtf8TextToOnlyExplicitEnvelopeRecipientsOnLocalSmtp() throws Exception {
try(var server=new ServerSocket(0,1,InetAddress.getByName("127.0.0.1"));var executor=Executors.newSingleThreadExecutor()) {
server.setSoTimeout(5000);var received=executor.submit(()->{var lines=new ArrayList<String>();try(var socket=server.accept()){socket.setSoTimeout(5000);var reader=new BufferedReader(new InputStreamReader(socket.getInputStream(),StandardCharsets.UTF_8));var writer=new PrintWriter(new OutputStreamWriter(socket.getOutputStream(),StandardCharsets.UTF_8),true);writer.print("220 localhost test\r\n");writer.flush();boolean data=false;for(String line;(line=reader.readLine())!=null;){lines.add(line);String response;if(data){if(!line.equals("."))continue;data=false;response="250 accepted";}else if(line.startsWith("DATA")){data=true;response="354 end with dot";}else if(line.startsWith("QUIT")){writer.print("221 bye\r\n");writer.flush();break;}else response="250 OK";writer.print(response+"\r\n");writer.flush();}}return lines;});
var transport=new AppointmentMailTransport("127.0.0.1",server.getLocalPort(),"","",false,false,"");transport.send("actor@example.test","reply@example.test",List.of("one@example.test","two@example.test"),"Termin geändert","Grüße aus Berlin");var lines=received.get(10,TimeUnit.SECONDS);assertThat(lines).contains("RCPT TO:<one@example.test>","RCPT TO:<two@example.test>","MAIL FROM:<actor@example.test>");assertThat(lines).anyMatch(l->l.startsWith("Content-Type: text/plain;")&&l.contains("UTF-8"));assertThat(lines).noneMatch(l->l.startsWith("Bcc:"));
}
}
@Test void deliversHtmlWithInlineImageAndPrivateBccThroughLocalSmtp() throws Exception {
try(var server=new ServerSocket(0,1,InetAddress.getByName("127.0.0.1"));var executor=Executors.newSingleThreadExecutor()) {
server.setSoTimeout(5000);var received=executor.submit(()->{var lines=new ArrayList<String>();try(var socket=server.accept()){socket.setSoTimeout(5000);var reader=new BufferedReader(new InputStreamReader(socket.getInputStream(),StandardCharsets.UTF_8));var writer=new PrintWriter(new OutputStreamWriter(socket.getOutputStream(),StandardCharsets.UTF_8),true);writer.print("220 localhost test\r\n");writer.flush();boolean data=false;for(String line;(line=reader.readLine())!=null;){lines.add(line);String response;if(data){if(!line.equals("."))continue;data=false;response="250 accepted";}else if(line.startsWith("DATA")){data=true;response="354 end with dot";}else if(line.startsWith("QUIT")){writer.print("221 bye\r\n");writer.flush();break;}else response="250 OK";writer.print(response+"\r\n");writer.flush();}}return lines;});
var transport=new AppointmentMailTransport("127.0.0.1",server.getLocalPort(),"","",false,false,"");
transport.sendHtml("actor@example.test","reply@example.test","to@example.test",List.of("cc@example.test"),List.of("hidden@example.test"),"Newsletter", "<h1>Abholung</h1><img src='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='>");
var lines=received.get(10,TimeUnit.SECONDS);assertThat(lines).contains("RCPT TO:<to@example.test>","RCPT TO:<cc@example.test>","RCPT TO:<hidden@example.test>","MAIL FROM:<actor@example.test>");assertThat(lines).noneMatch(l->l.startsWith("Bcc:"));
int begin=lines.indexOf("DATA")+1;int end=lines.subList(begin,lines.size()).indexOf(".")+begin;
var message=new jakarta.mail.internet.MimeMessage(jakarta.mail.Session.getInstance(new Properties()),new ByteArrayInputStream(String.join("\r\n",lines.subList(begin,end)).getBytes(StandardCharsets.UTF_8)));
assertThat(message.getRecipients(jakarta.mail.Message.RecipientType.TO)).hasSize(1);assertThat(message.getRecipients(jakarta.mail.Message.RecipientType.CC)).hasSize(1);assertThat(message.getRecipients(jakarta.mail.Message.RecipientType.BCC)).isNull();
var parts=new ArrayList<jakarta.mail.Part>();collect(message,parts);
assertThat(parts.stream().filter(p->{try{return p.isMimeType("text/html");}catch(Exception e){throw new RuntimeException(e);}}).toList()).singleElement().satisfies(p->{try{assertThat(p.getContent().toString()).contains("cid:newsletter-image-0").doesNotContain("data:image");}catch(Exception e){throw new RuntimeException(e);}});
assertThat(parts.stream().filter(p->{try{return p.isMimeType("image/png");}catch(Exception e){throw new RuntimeException(e);}}).toList()).singleElement().satisfies(p->{try{assertThat(p.getHeader("Content-ID")).containsExactly("<newsletter-image-0>");assertThat(p.getInputStream().readAllBytes()).hasSizeGreaterThan(50);}catch(Exception e){throw new RuntimeException(e);}});
}
}
static void collect(jakarta.mail.Part part,List<jakarta.mail.Part> parts)throws Exception{if(part.isMimeType("multipart/*")){var multi=(jakarta.mail.Multipart)part.getContent();for(int i=0;i<multi.getCount();i++)collect(multi.getBodyPart(i),parts);}else parts.add(part);}
}
@@ -0,0 +1,21 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import java.util.*;import java.time.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryService.fields;
class AppointmentNotificationsTest {
final DatabaseService db=mock(DatabaseService.class);final AppointmentService appointments=mock(AppointmentService.class);final AppointmentMailTransport transport=spy(new AppointmentMailTransport("127.0.0.1",1,"","",false,false,""));final AppointmentNotifications service=new AppointmentNotifications(db,appointments,transport);final LegacyUser user=AppointmentServiceTest.user(0,11);
final Map<String,Object> row=fields("ap_id",44L,"usr_id",11L,"ap_participants",",11,22,33,","ap_text","<script>Plain text only</script>","ap_execdate",LocalDateTime.of(2026,10,13,9,0),"ap_enddate",LocalDateTime.of(2026,10,13,10,0));
AppointmentNotifications.Draft draft(){return new AppointmentNotifications.Draft(AppointmentNotifications.Kind.CHANGED,true,"TEST@example.test,external@example.test","Hallo","Viele Grüße");}
@BeforeEach void setup(){when(appointments.appointment(user,44)).thenReturn(row);when(db.queryForList(startsWith("SELECT usr_email"),any(Object[].class))).thenReturn(List.of(fields("usr_email","test@example.test")));when(db.queryForList(startsWith("SELECT DISTINCT u.usr_id"),any(Object[].class))).thenReturn(List.of(fields("usr_id",11L,"usr_email","test@example.test","label","Ich"),fields("usr_id",22L,"usr_email","person@example.test","label","Teilnehmer"),fields("usr_id",33L,"usr_email","","label","Ohne Mail")));doNothing().when(transport).send(anyString(),anyString(),anyList(),anyString(),anyString());}
@Test void previewDeduplicatesRecipientsAndReportsMissingAddressesWithoutSending(){var p=service.preview(user,44,draft());assertThat((List<?>)p.get("recipients")).hasSize(3);assertThat((List<String>)p.get("missing")).containsExactly("Ohne Mail");assertThat(p.get("body").toString()).contains("Plain text only");verify(transport,never()).send(anyString(),anyString(),anyList(),anyString(),anyString());}
@Test void uncheckedParticipantsAreNotSentExternalOnlyNotification(){var p=service.preview(user,44,new AppointmentNotifications.Draft(AppointmentNotifications.Kind.NEW,false,"external@example.test","",""));assertThat((List<String>)p.get("recipients")).containsExactly("external@example.test");}
@Test void headerInjectionAndAddressListsInSingleAddressAreRejected(){for(String value:List.of("x@example.test\r\nBcc:evil@example.test","a@example.test,b@example.test","Name <a@example.test>","not-an-address"))assertThatThrownBy(()->AppointmentNotifications.address(value)).hasMessageContaining("400");}
@Test void repeatedSendOfSamePreviewDoesNotDuplicateMail(){var p=service.preview(user,44,draft());String token=(String)p.get("token");assertThat(service.send(user,token)).containsEntry("sent",true);service.send(user,token);verify(transport,times(1)).send(anyString(),anyString(),anyList(),anyString(),anyString());verify(db,never()).update(anyString(),any(Object[].class));}
@Test void failedSendNeverRewritesAppointmentOrAutomaticallyRetries(){doThrow(new org.springframework.mail.MailSendException("private relay details")).when(transport).send(anyString(),anyString(),anyList(),anyString(),anyString());String token=(String)service.preview(user,44,draft()).get("token");var r=service.send(user,token);assertThat(r).containsEntry("sent",false);assertThat(r.toString()).doesNotContain("private relay");service.send(user,token);verify(transport,times(1)).send(anyString(),anyString(),anyList(),anyString(),anyString());verify(db,never()).writeTransaction(any());}
@Test void changedAppointmentRequiresNewPreview(){String token=(String)service.preview(user,44,draft()).get("token");row.put("ap_text","changed");assertThatThrownBy(()->service.send(user,token)).hasMessageContaining("409");verify(transport,never()).send(anyString(),anyString(),anyList(),anyString(),anyString());}
@Test void previewCannotBeUsedByOtherAccountAndCancellationRequiresDeleteRight(){String token=(String)service.preview(user,44,draft()).get("token");var other=new LegacyUser(99,99,7,"other",user.rights());assertThatThrownBy(()->service.send(other,token)).hasMessageContaining("400");row.put("usr_id",22L);assertThatThrownBy(()->service.preview(user,44,new AppointmentNotifications.Draft(AppointmentNotifications.Kind.CANCELLED,true,"","",""))).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);}
}
@@ -0,0 +1,161 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import org.springframework.security.access.AccessDeniedException;
import java.time.*;
import java.util.*;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static de.stadtbote.votian.OrderEntryService.fields;
import static de.stadtbote.votian.AppointmentService.*;
class AppointmentServiceTest {
final DatabaseService db=mock(DatabaseService.class);
final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);
final AppointmentService service=new AppointmentService(db,catalog);
final LegacyUser user=user(0,11),master=user(0,3,10,11);
final LocalDate date=LocalDate.of(2026,10,12);
final List<String> queries=new ArrayList<>();
final List<List<Object>> arguments=new ArrayList<>();
Map<String,Object> row=fields("ap_id",44L,"hq_id",7L,"usr_id",11L,"ap_type",0,"ap_category_1",1,"ap_category_2",1,"ap_category_3",2,"ap_category_4",1,"ap_participants",",11,22,","ap_text","Besprechung","ap_execdate",date.atTime(9,0),"ap_enddate",date.atTime(10,0),"ap_confirmed",",22,","ap_modifytime",date.atTime(8,0),"cs_id",9L,"csc_id",0,"pt_id",0,"customer_label","Testkunde");
boolean missing=false,reportExists=false;
Map<String,Object> inserted;String updatedSql;List<Object> updatedArgs;
static LegacyUser user(int... rights) { char[] bits="0".repeat(70).toCharArray();for(int r:rights)bits[r]='1';return new LegacyUser(11,12,7,"test",new String(bits)); }
Query query() { return new Query(date,date,List.of(7L),null,null,null,null,null,List.of(Visibility.values()),""); }
Save save() { return new Save(AdminSupport.revision(version(row)),date.atTime(9,0),date.atTime(10,0),"Neu",1,1,2,1,9L,List.of(11L,22L)); }
Save with(Save r,LocalDateTime from,LocalDateTime to,int monitor,Long customer,List<Long> people,String text) { return new Save(r.revision(),from,to,text,r.status(),r.visibility(),r.kind(),monitor,customer,people); }
@BeforeEach void setup() {
when(db.readTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.writeTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{
String sql=c.getArgument(0);var args=Arrays.asList(c.getArguments()).subList(1,c.getArguments().length);queries.add(sql);arguments.add(args);
if(sql.startsWith("SELECT mt_sort"))return args.getFirst().equals("appointment_category_4")?List.of(fields("id",0L,"label","Keine"),fields("id",1L,"label","Aktiv")):List.of(fields("id",1L,"label","Eins"),fields("id",2L,"label","Zwei"),fields("id",3L,"label","Drei"));
if(sql.startsWith("SELECT DISTINCT h.hq_id"))return sql.contains(" AND h.hq_id=?")?List.of(fields("id",7L,"label","B")):List.of(fields("id",7L,"label","B"),fields("id",8L,"label","HH"));
if(sql.startsWith("SELECT u.usr_id AS id"))return List.of(fields("id",11L,"label","Tester","groups",",24,"),fields("id",22L,"label","Teilnehmer","groups",",24,"));
if(sql.startsWith("SELECT DISTINCT u.usr_id"))return List.of(fields("id",11L,"label","Tester"),fields("id",22L,"label","Teilnehmer"));
if(sql.startsWith("SELECT cs_id FROM"))return List.of(fields("cs_id",9L));
if(sql.startsWith("SELECT md_id"))return List.of(fields("md_id",1L));
if(sql.startsWith("SELECT grp_id"))return List.of(fields("id",24L,"label","Dispo"));
if(sql.startsWith("SELECT a.ap_category_1"))return List.of(fields("ap_category_1",1,"ap_category_2",1,"ap_category_3",2,"ap_category_4",1,"total",3));
if(sql.startsWith("SELECT a.ap_id"))return missing?List.of():List.of(row);
if(sql.startsWith("SELECT rp_id"))return reportExists?List.of(fields("rp_id",1L)):List.of();
return List.of();
});
when(db.insert(anyString(),anyMap())).thenAnswer(c->{inserted=new LinkedHashMap<>(c.getArgument(1));return 45L;});
when(db.update(anyString(),any(Object[].class))).thenAnswer(c->{updatedSql=c.getArgument(0);updatedArgs=Arrays.asList(c.getArguments()).subList(1,c.getArguments().length);return 1;});
}
@Test void bothSalesAndCustomerRightsRequiredBeforeDataAccess() {
for(var who:List.of(user(0),user(11))) {
assertThatThrownBy(()->service.list(who,query())).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->service.create(who,save())).isInstanceOf(AccessDeniedException.class);
assertThat(who.capabilities().readAppointments()).isFalse();
}
verifyNoInteractions(db,catalog);assertThat(user.capabilities().readAppointments()).isTrue();
}
@Test void privacyRemainsMandatoryEvenForMasterAndAllVisibilityFilters() {
for(var who:List.of(user,master)) {service.list(who,query());service.statistics(who,query());}
assertThat(queries.stream().filter(q->q.contains("FROM "+TABLE))).hasSize(4).allMatch(q->q.contains("(a.usr_id=? OR a.ap_category_2=3 OR (a.ap_category_2=1 AND a.ap_participants LIKE ?))"));
assertThat(arguments.stream().filter(a->a.contains("%,11,%"))).hasSize(4);
}
@Test void overlapsAndPersonFiltersUseBoundWholeIds() {
var q=new Query(date,date,List.of(7L),22L,9L,1,2,1,List.of(Visibility.INVITED),"' OR 1=1");
service.list(user,q);
int i=queries.size()-2; // participant name lookup follows the calendar SELECT
assertThat(queries.get(i)).contains("a.ap_execdate<? AND (a.ap_execdate>=? OR a.ap_enddate>=?)","a.cs_id=?","a.ap_category_3=?","a.ap_text LIKE ?").doesNotContain("' OR 1=1");
assertThat(arguments.get(i)).contains("%,22,%","%' OR 1=1%",date.plusDays(1).atStartOfDay(),date.atStartOfDay());
}
@Test void crossHeadquartersRequiresMasterWithinSameMandator() {
var q=new Query(date,date,List.of(8L),null,null,null,null,null,query().visibility(),"");
assertThatThrownBy(()->service.list(user,q)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->service.list(master,new Query(date,date,List.of(99L),null,null,null,null,null,query().visibility(),""))).isInstanceOf(AccessDeniedException.class);
assertThat(queries).noneMatch(s->s.contains("FROM "+TABLE));
service.list(master,q);
}
@Test void emptyAndInvalidFiltersNeverBroadenToAllAppointments() {
for(var q:List.of(new Query(date,date,List.of(),null,null,null,null,null,query().visibility(),""),new Query(date,date,List.of(7L),null,null,null,null,null,List.of(),""),new Query(date,date.plusDays(366),List.of(7L),null,null,null,null,null,query().visibility(),"")))assertThatThrownBy(()->service.list(user,q)).hasMessageContaining("400");
assertThat(queries).noneMatch(s->s.contains("FROM "+TABLE));
validate(new Query(LocalDate.of(2024,1,1),LocalDate.of(2024,12,31),List.of(7L),null,null,null,null,null,query().visibility(),""),false);
}
@Test void statisticsCountsAppointmentsOnceAndIncludesUnusedCategories() {
var result=service.statistics(user,query());
assertThat(result).containsEntry("total",3L);
@SuppressWarnings("unchecked") var categories=(List<Map<String,Object>>)result.get("categories");
assertThat(categories).hasSize(4);assertThat(categories.get(0).get("items").toString()).contains("total=0","total=3");
assertThat(queries).anyMatch(s->s.contains("COUNT(*) AS total")&&s.contains("GROUP BY a.ap_category_1"));
}
@Test void optionsIncludesRealEmployeeGroupsAndCompletedControl() {
var result=service.options(user);
assertThat(result.get("groups").toString()).contains("Dispo","11","22");
assertThat(result.get("categories").toString()).contains("Erledigt");
}
@Test void createsInSessionHeadquartersWithNoInheritedConfirmations() {
assertThat(service.create(user,save())).containsEntry("id",45L);
assertThat(inserted).containsEntry("usr_id",11L).containsEntry("hq_id",7L).containsEntry("ap_confirmed","").containsEntry("ap_participants",",11,22,").containsEntry("csc_id",null);
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void malformedTimesUnknownParticipantsAndLargeUtf8TextCannotBeSaved() {
var s=save();
for(var invalid:List.of(with(s,s.endsAt(),s.startsAt(),1,9L,s.participants(),"x"),with(s,s.startsAt().plusSeconds(1),s.endsAt(),1,9L,s.participants(),"x"),with(s,s.startsAt(),s.endsAt(),1,9L,List.of(999L),"x"),with(s,s.startsAt(),s.endsAt(),1,9L,s.participants(),"€".repeat(22000)),with(s,s.startsAt(),s.endsAt(),2,9L,s.participants(),"x")))assertThatThrownBy(()->service.create(user,invalid)).isInstanceOf(RuntimeException.class);
verify(db,never()).insert(anyString(),anyMap());
}
@Test void participantMayEditButCannotDeleteAnotherAuthorsAppointment() {
row.put("usr_id",22L);service.save(user,44,save());
assertThatThrownBy(()->service.remove(user,44,save().revision())).isInstanceOf(AccessDeniedException.class);
row.put("ap_participants",",22,");assertThatThrownBy(()->service.save(user,44,save())).isInstanceOf(AccessDeniedException.class);
}
@Test void editorPreservesAuthorHeadquartersAndHiddenContactLinks() {
row.put("csc_id",81L);row.put("pt_id",83L);service.save(master,44,save());
String set=updatedSql.substring(0,updatedSql.indexOf(" WHERE "));
assertThat(set).doesNotContain("usr_id=","hq_id=","csc_id=","pt_id=");
var s=save();assertThatThrownBy(()->service.save(master,44,with(s,s.startsAt(),s.endsAt(),1,null,s.participants(),"x"))).hasMessageContaining("Kontaktverknüpfung");
}
@Test void movingTimeClearsConfirmationButTextOnlyEditRetainsIt() {
service.save(user,44,save());assertThat(updatedArgs.get(9)).isEqualTo(",22,");
var s=save();service.save(user,44,with(s,s.startsAt().plusDays(1),s.endsAt().plusDays(1),1,9L,s.participants(),"x"));assertThat(updatedArgs.get(9)).isEqualTo("");
}
@Test void removedParticipantLosesConfirmationAndOldUnavailableParticipantsCanBePreserved() {
var s=save();service.save(user,44,with(s,s.startsAt(),s.endsAt(),1,9L,List.of(11L),"x"));assertThat(updatedArgs.get(9)).isEqualTo("");
row.put("ap_participants",",11,999,");s=save();service.save(user,44,with(s,s.startsAt(),s.endsAt(),1,9L,List.of(11L,999L),"x"));assertThat(updatedArgs.get(8)).isEqualTo(",11,999,");
}
@Test void staleRevisionOrConcurrentLegacyMutationCannotOverwriteData() {
var s=save();assertThatThrownBy(()->service.save(user,44,new Save("old",s.startsAt(),s.endsAt(),"x",1,1,2,1,9L,s.participants()))).hasMessageContaining("409");
when(db.update(anyString(),any(Object[].class))).thenReturn(0);
assertThatThrownBy(()->service.save(user,44,save())).hasMessageContaining("409");
assertThatThrownBy(()->service.remove(user,44,save().revision())).hasMessageContaining("409");
verify(db,times(2)).update(contains("BINARY ap_text <=> BINARY ?"),any(Object[].class)); // UPDATE and DELETE both use every original column
}
@Test void inaccessibleAppointmentsCannotBeEditedEvenWithAdministrativeRights() {
missing=true;assertThatThrownBy(()->service.save(master,44,save())).hasMessageContaining("404");
assertThat(queries).anyMatch(s->s.contains("a.ap_id=? AND a.hq_id IN (?,?) AND (a.usr_id=? OR a.ap_category_2=3"));
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void confirmsOnlyCurrentParticipantAndKeepsOtherConfirmations() {
service.confirm(user,44,save().revision());assertThat(updatedArgs.getFirst()).isEqualTo(",11,22,");
row.put("ap_participants",",22,");assertThatThrownBy(()->service.confirm(master,44,save().revision())).isInstanceOf(AccessDeniedException.class);
}
@Test void finishRequiresReportWhenConfiguredAndRejectsInactiveAppointments() {
when(catalog.enabled(0,"MASK_AP_WARNING_DISABLE_QUESTION_NO_REPORT_OF_SAME_DAY")).thenReturn(true);
assertThatThrownBy(()->service.finish(user,44,save().revision())).hasMessageContaining("Kundenbericht");
verify(db,never()).update(anyString(),any(Object[].class));
reportExists=true;service.finish(user,44,save().revision());assertThat(updatedArgs.getFirst()).isEqualTo(2);
assertThat(queries).anyMatch(s->s.contains("rp_objid=? AND hq_id=?")&&s.contains("rp_confidential=0 OR usr_id=?"));
row.put("ap_category_4",0);assertThatThrownBy(()->service.finish(user,44,save().revision())).hasMessageContaining("überwachter Termin");
}
@Test void editorCannotBypassReportRequirementByFinishingAnUnmonitoredAppointment() {
row.put("ap_category_4",0);when(catalog.enabled(0,"MASK_AP_WARNING_DISABLE_QUESTION_NO_REPORT_OF_SAME_DAY")).thenReturn(true);
var s=save();assertThatThrownBy(()->service.save(user,44,with(s,s.startsAt(),s.endsAt(),2,9L,s.participants(),"x"))).hasMessageContaining("Kundenbericht");
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void resubmissionPreservesSourceRelationsResetsConfirmationsAndReactivatesMonitoring() {
row.put("ap_category_4",2);row.put("csc_id",8L);row.put("pt_id",6L);
var result=service.resubmit(user,44,new Resubmission(save().revision(),date.plusDays(7).atTime(9,0),date.plusDays(7).atTime(10,0)));
assertThat(result).containsEntry("id",45L);assertThat(inserted).doesNotContainKey("ap_id").containsEntry("csc_id",8L).containsEntry("pt_id",6L).containsEntry("ap_confirmed","").containsEntry("ap_category_4",1).containsEntry("usr_id",11L);
verify(db,never()).update(anyString(),any(Object[].class));assertThat(row).containsEntry("ap_confirmed",",22,");
}
@Test void legacyZeroCustomerIsReturnedAsNoAssignment() {
row.put("cs_id",0);@SuppressWarnings("unchecked") var items=(List<Map<String,Object>>)service.list(user,query()).get("items");assertThat(items.getFirst()).containsEntry("customerId",null);
}
}
@@ -0,0 +1,71 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseService;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryService.fields;
/** End-to-end business writes against disposable MariaDB, never the legacy host. */
public class BusinessIntegrationAssertions {
static String token(){return UUID.randomUUID().toString();}
@SuppressWarnings("unchecked") static Map<String,Object> values(Map<String,Object> view){return new LinkedHashMap<>((Map<String,Object>)view.get("values"));}
static MasterData.Save save(Map<String,Object> view,Map<String,Object> values){return new MasterData.Save(token(),text(view,"revision"),values);}
@SuppressWarnings("unchecked") public static void verify(DatabaseService db){
var u=AppointmentServiceTest.user(0,1,2,4,5,7,38,41);var support=new BusinessSupport(db,new ObjectMapper().findAndRegisterModules());var masters=new MasterData(db,support);
var blank=masters.view(u,"customers",0,0);var input=values(blank);input.putAll(fields("cmp_comp","QA Kunde","cs_eid","QA001","ad_street","Teststraße","cmp_hsno","1","ad_zipcode","10115","ad_city","Berlin","usr_name","Test","usr_phone","030123","usr_inv_email","test@example.invalid"));
var create=save(blank,input);var customer=masters.save(u,"customers",0,0,create);long cs=id(customer,"id");assertThat(masters.save(u,"customers",0,0,create)).containsEntry("id",cs);
assertThat(db.queryForObject("SELECT COUNT(*) FROM customer",Integer.class)).isEqualTo(1);
long center=id(db.queryForList("SELECT csc_id FROM customer WHERE cs_id=?",cs).getFirst(),"csc_id");
assertThat(db.queryForObject("SELECT COUNT(*) FROM costcenteraddress WHERE csc_id=?",Integer.class,center)).isEqualTo(3);
long originalAddress=id(db.queryForList("SELECT ad_id FROM company WHERE cmp_comp='QA Kunde'").getFirst(),"ad_id");input=values(customer);input.put("ad_street","Neue Straße");var changed=masters.save(u,"customers",cs,0,save(customer,input));assertThat(text(db.queryForList("SELECT ad_street FROM address WHERE ad_id=?",originalAddress).getFirst(),"ad_street")).isEqualTo("Teststraße");
final var oldCustomer=customer;final var staleValues=input;assertThatThrownBy(()->masters.save(u,"customers",cs,0,save(oldCustomer,staleValues))).hasMessageContaining("409");
var cview=masters.view(u,"centers",center,cs);var cvalues=values(cview);cvalues.put("addr2_cscad_comp","Rechnungsempfänger");masters.save(u,"centers",center,cs,save(cview,cvalues));assertThat(text(db.queryForList("SELECT cscad_comp FROM costcenteraddress WHERE csc_id=? AND adt_id=2",center).getFirst(),"cscad_comp")).isEqualTo("Rechnungsempfänger");
var childBlank=masters.view(u,"centers",0,cs);var childValues=values(childBlank);childValues.put("csc_name","Zweigstelle");var child=masters.save(u,"centers",0,cs,save(childBlank,childValues));assertThat(db.queryForList("SELECT csc_path FROM costcenter WHERE csc_id=?",id(child,"id")).getFirst().get("csc_path")).isEqualTo("//QA Kunde//");
var listing=new LegacyReadService(db,500);
assertThat(listing.customers(u,"QA",false,null)).hasSize(1);
assertThat(listing.customers(u,"QA",true,null)).hasSize(2);
assertThat(listing.customers(u,"QA",true,"Zweig")).singleElement().satisfies(row->{assertThat(row.id).isEqualTo(cs);assertThat(row.costCenterId).isEqualTo(id(child,"id"));assertThat(row.costCenterName).isEqualTo("Zweigstelle");});
assertThat(listing.customers(u,"QA",true,"' OR 1=1 --")).isEmpty();
assertThat(listing.customers(new LegacyUser(22,22,8,"foreign","1".repeat(70)),"QA",true,"Zweig")).isEmpty();
childValues=values(child);childValues.put("csc_pre_id",id(child,"id"));var cycle=save(child,childValues);assertThatThrownBy(()->masters.save(u,"centers",id(child,"id"),cs,cycle)).hasMessageContaining("Kreis");
var crblank=masters.view(u,"couriers",0,0);var crvalues=values(crblank);crvalues.putAll(fields("cmp_comp","QA Kurier","cr_eid","QACR01","cmp_hsno","2","usr_name","Kurier","usr_phone","030123","ad_street","Testweg","ad_zipcode","10115","ad_city","Berlin"));var courier=masters.save(u,"couriers",0,0,save(crblank,crvalues));long cr=id(courier,"id");
var vblank=masters.view(u,"vehicles",0,cr);var vvalues=values(vblank);vvalues.putAll(fields("crvh_sid","QA01","vht_id",1));var vehicle=masters.save(u,"vehicles",0,cr,save(vblank,vvalues));assertThat(id(vehicle,"id")).isPositive();
long address=originalAddress;long job=db.writeTransaction(()->{long j=db.insert("job",fields("hq_id",7,"csc_id_payer",center,"jb_status",1,"cr_id",cr,"cr_id_order",cr,"cr_sid","QA01","jb_createtime",LocalDateTime.now(),"jb_ordertime",LocalDateTime.now().minusHours(1),"jb_freetext_1","","jb_service","","jb_orderer","","jb_scrm","","jb_totalprice",100,"jb_cr_price",80,"jb_sales_tax_rate",19,"jb_payment",0,"jb_postage",0));for(int i=1;i<=2;i++)db.insert("tour",fields("jb_id",j,"tr_sort",i,"ad_id",address,"tr_comp","Station "+i,"tr_ware_from_to",i==1?2:1,"tr_status",i==1?1:0,"tr_signname",i==1?"Originalempfänger":"","tr_cs_freetext",""));BusinessSupport.insertWithoutKey(db,"tourservice",fields("jb_id",j,"csc_id",center,"tr_sort",0,"srv_id",0,"srvt_id",0,"trs_srv_name","Fixpreis","trs_srvt_name","","trs_price",100,"trs_discount",0));return j;});
var catalog=new OrderEntryCatalog(db);var ops=new OrderOperations(db,support,catalog,new LegacyOrderPricing(db,catalog));var work=ops.view(u,job);var vals=values(work);vals.put("jb_orderer","Geändert");var stations=((List<Map<String,Object>>)work.get("stations")).stream().map(st->new OrderOperations.Station(id(st,"id"),values(st))).toList();var edit=new OrderOperations.Edit(token(),text(work,"revision"),vals,stations);ops.edit(u,job,edit);ops.edit(u,job,edit);assertThatThrownBy(()->ops.edit(u,job,new OrderOperations.Edit(token(),text(work,"revision"),vals,stations))).hasMessageContaining("409");
var recalc=new OrderRecalculation(db,support,ops,catalog,new LegacyOrderPricing(db,catalog),mock(OrderRoutingService.class),jakarta.validation.Validation.buildDefaultValidatorFactory().getValidator());
db.writeTransaction(()->{OrderPaymentTerms.capture(db,job,cs);db.update("INSERT INTO jobprice(jb_id,mt_sort,jbprc_price,jbprc_remark) VALUES (?,16,12.5,'')",job);return null;});
var recalcInput=new OrderRecalculation.Input(new OrderEntryModels.Pricing(OrderEntryModels.PriceMode.FIXED,new BigDecimal("130.00"),BigDecimal.ZERO,new BigDecimal("85.00"),BigDecimal.ZERO,BigDecimal.ZERO,BigDecimal.ZERO,false,1L),1,List.of(),false,false);
var recalcDraft=new OrderRecalculation.Command(token(),text(ops.view(u,job),"revision"),recalcInput,null);var recalcPreview=recalc.preview(u,job,recalcDraft);
var recalcSave=new OrderRecalculation.Command(recalcDraft.requestId(),recalcDraft.revision(),recalcInput,text(recalcPreview,"fingerprint"));
recalc.save(u,job,recalcSave);recalc.save(u,job,recalcSave);
assertThat(decimal(db.queryForList("SELECT jb_totalprice FROM job WHERE jb_id=?",job).getFirst(),"jb_totalprice")).isEqualByComparingTo("130");
assertThat(db.queryForObject("SELECT COUNT(*) FROM job",Integer.class)).isEqualTo(1);
assertThat(db.queryForObject("SELECT COUNT(*) FROM tour WHERE jb_id=?",Integer.class,job)).isEqualTo(2);
assertThat(db.queryForObject("SELECT jbprc_price FROM jobprice WHERE jb_id=? AND mt_sort=16",BigDecimal.class,job)).isEqualByComparingTo("12.5");
assertThat(OrderPaymentTerms.read(db,job)).containsEntry("label","10 Tage netto");
var recalcOptions=recalc.options(u,job);
var fresh=ops.view(u,job);var price=new OrderOperations.Price(token(),text(fresh,"revision"),new BigDecimal("125.50"),new BigDecimal("85.00"),"Testkorrektur");ops.price(u,job,price);ops.price(u,job,price);assertThat(decimal(db.queryForList("SELECT jb_totalprice FROM job WHERE jb_id=?",job).getFirst(),"jb_totalprice")).isEqualByComparingTo("125.50");
try{support.json.writeValue(new java.io.File("target/business-ui-fixture.json"),fields("customer",masters.view(u,"customers",cs,0),"courier",masters.view(u,"couriers",cr,0),"center",masters.view(u,"centers",center,cs),"child",masters.view(u,"centers",id(child,"id"),cs),"vehicle",masters.view(u,"vehicles",id(vehicle,"id"),cr),"order",ops.view(u,job),"calculation",recalcOptions,"calculationPreview",recalcPreview));}catch(java.io.IOException e){throw new IllegalStateException(e);}
var done=new OrderOperations.Action(token(),text(ops.view(u,job),"revision"),"COMPLETE",null,null,"Neuer Empfänger",null,false,false);ops.action(u,job,done);ops.action(u,job,done);assertThat(text(db.queryForList("SELECT tr_signname FROM tour WHERE jb_id=? AND tr_sort=1",job).getFirst(),"tr_signname")).isEqualTo("Originalempfänger");assertThat(id(db.queryForList("SELECT jb_status FROM job WHERE jb_id=?",job).getFirst(),"jb_status")).isEqualTo(2);
var billing=new BillingService(db,support);var q=new BillingService.Query(LocalDate.now().minusDays(1),LocalDate.now(),"CUSTOMER",cs,null,true,null,"",false);var statement=billing.query(u,q);assertThat((BigDecimal)statement.get("net")).isEqualByComparingTo("125.50");assertThat((BigDecimal)statement.get("gross")).isEqualByComparingTo("149.35");
var assignment=new BillingService.Assign(token(),q,text(statement,"revision"),List.of(job),"QA-2026-001",LocalDate.now());
// Invoice uses MyISAM: simulate losing acknowledgement after the insert, then safely resume.
var failing=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("lost acknowledgement")).when(failing).update(startsWith("UPDATE genericdatacontainer SET gdc_context='DONE'"),any(Object[].class));var broken=new BillingService(failing,new BusinessSupport(failing,support.json));assertThatThrownBy(()->broken.assign(u,assignment)).hasMessageContaining("lost acknowledgement");assertThat(db.queryForObject("SELECT COUNT(*) FROM invoice WHERE jb_id=?",Integer.class,job)).isEqualTo(1);billing.assign(u,assignment);billing.assign(u,assignment);assertThat(db.queryForObject("SELECT COUNT(*) FROM invoice WHERE jb_id=?",Integer.class,job)).isEqualTo(1);assertThat(db.queryForObject("SELECT COUNT(*) FROM genericdatacontainer WHERE gdc_gen_fieldname='ng_invoice_lock'",Integer.class)).isZero();
assertThatThrownBy(()->ops.price(u,job,new OrderOperations.Price(token(),text(ops.view(u,job),"revision"),BigDecimal.ONE,BigDecimal.ONE,"Nach Rechnung"))).hasMessageContaining("bereits exportiert");
try{java.nio.file.Files.write(java.nio.file.Path.of("target/business-statement.pdf"),StatementPdf.create(billing.query(u,q),q));java.nio.file.Files.write(java.nio.file.Path.of("target/business-statement.csv"),billing.csv(u,q));}catch(java.io.IOException e){throw new IllegalStateException(e);}
var credit=new OrderOperations.Action(token(),text(ops.view(u,job),"revision"),"CREDIT",null,null,null,"Testgutschrift",false,false);var credited=ops.action(u,job,credit);long negative=id(credited,"resultId");assertThat(negative).isNotEqualTo(job);assertThat(ops.action(u,job,credit)).containsEntry("resultId",negative);assertThat(decimal(db.queryForList("SELECT jb_totalprice FROM job WHERE jb_id=?",negative).getFirst(),"jb_totalprice")).isEqualByComparingTo("-125.50");assertThat((BigDecimal)billing.query(u,q).get("net")).isEqualByComparingTo("0.00");
assertThatThrownBy(()->masters.view(new LegacyUser(22,22,8,"foreign","1".repeat(70)),"customers",cs,0)).hasMessageContaining("404");
support.publish();long logs=db.queryForObject("SELECT COUNT(*) FROM phoenix_log.log",Long.class);assertThat(logs).isPositive();support.publish();assertThat(db.queryForObject("SELECT COUNT(*) FROM phoenix_log.log",Long.class)).isEqualTo(logs);
// A main-transaction failure must not leave a changed master record or a partial price rewrite.
var rejectReceipt=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("write failure")).when(rejectReceipt).insert(eq("genericdatacontainer"),anyMap());var rollbackMasters=new MasterData(rejectReceipt,new BusinessSupport(rejectReceipt,support.json));var currentMaster=masters.view(u,"customers",cs,0);var revisedValues=values(currentMaster);revisedValues.put("cmp_comp","Must roll back");assertThatThrownBy(()->rollbackMasters.save(u,"customers",cs,0,save(currentMaster,revisedValues))).hasMessageContaining("write failure");assertThat(values(masters.view(u,"customers",cs,0)).get("cmp_comp")).isEqualTo("QA Kunde");
// Detect an old-PHP edit between durable intent and a resumed MyISAM assignment.
var before=billing.query(u,q);var next=new BillingService.Assign(token(),q,text(before,"revision"),List.of(negative),"QA-CREDIT",LocalDate.now());var interrupted=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("invoice unavailable")).when(interrupted).update(startsWith("INSERT INTO invoice"),any(Object[].class));var interruptedBilling=new BillingService(interrupted,new BusinessSupport(interrupted,support.json));assertThatThrownBy(()->interruptedBilling.assign(u,next)).hasMessageContaining("invoice unavailable");db.writeTransaction(()->db.update("UPDATE job SET jb_incomplete=1 WHERE jb_id=?",negative));assertThatThrownBy(()->billing.resume(u,next.requestId())).hasMessageContaining("seit Beginn");assertThat(db.queryForObject("SELECT COUNT(*) FROM invoice WHERE jb_id=?",Integer.class,negative)).isZero();billing.stop(u,next.requestId());assertThat(db.queryForObject("SELECT COUNT(*) FROM genericdatacontainer WHERE gdc_gen_fieldname='ng_invoice_lock'",Integer.class)).isZero();
assertThat(new String(billing.csv(u,q),java.nio.charset.StandardCharsets.UTF_8)).contains("QA-2026-001","125.50");var pdf=StatementPdf.create(billing.query(u,q),q);assertThat(new String(pdf,StatementPdf.WIN)).startsWith("%PDF-1.4").contains("xref","Rechnungsanlage");
}
}
@@ -0,0 +1,85 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import org.springframework.security.access.AccessDeniedException;
import java.time.*;
import java.util.*;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryService.fields;
import static de.stadtbote.votian.CalendarAdministration.*;
class CalendarAdministrationTest {
final DatabaseService db=mock(DatabaseService.class);
final LegacyUser user=new LegacyUser(11,12,7,"test","1".repeat(70));
final CalendarAdministration service=new CalendarAdministration(db,Clock.fixed(Instant.parse("2026-10-12T09:00:00Z"),ZoneId.of("Europe/Berlin")));
final List<Map<String,Object>> holidays=new ArrayList<>();
final Map<String,Object> travel=fields("id",1L,"postalCode","10115","country","DE","globalMinutes",30,"localMinutes",null);
@BeforeEach void setup(){
when(db.readTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.writeTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.lockRows(anyString(),any(Object[].class))).thenAnswer(c->((String)c.getArgument(0)).startsWith("SELECT ph_id")?holidays:List.of(fields("hq_id",7)));
when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);if(sql.startsWith("SELECT ph_id"))return holidays;if(sql.startsWith("SELECT p.srvp_id"))return List.of(travel);return List.of();});
when(db.update(anyString(),any(Object[].class))).thenReturn(1);
}
@Test void rightsAreCheckedBeforeAnyDatabaseAccess(){
var denied=new LegacyUser(11,12,7,"test","0".repeat(70));
assertThatThrownBy(()->service.holidays(denied,2026)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->service.saveTravelTime(denied,TravelMode.BWV,1,new TravelSave("x",20))).isInstanceOf(AccessDeniedException.class);
verifyNoInteractions(db);
}
@Test void historicalRowsAndTimeWindowsSurviveAnEmptyFutureCalendar(){
holidays.add(fields("ph_id",1,"ph_month",1,"ph_day",1,"ph_name","Neujahr","ph_time_from","10:00","ph_time_to","12:00"));
holidays.add(fields("ph_id",2,"ph_month",12,"ph_day",25,"ph_name","Weihnachten","ph_time_from","00:00","ph_time_to","00:00"));
service.saveHolidays(user,2026,new HolidaySave(AdminSupport.revision(holidays),List.of()));
verify(db).update("DELETE FROM publicholiday WHERE ph_id=? AND hq_id=?",2L,7L);
verify(db,never()).update(eq("DELETE FROM publicholiday WHERE ph_id=? AND hq_id=?"),eq(1L),any());
}
@Test void staleCalendarCannotOverwriteAnotherEditorsChanges(){
assertThatThrownBy(()->service.saveHolidays(user,2026,new HolidaySave("outdated",List.of()))).hasMessageContaining("409");
verify(db,never()).update(anyString(),any(Object[].class));verify(db,never()).insert(anyString(),anyMap());
}
@Test void validatesDateAndRejectsModificationOfToday(){
assertThatThrownBy(()->service.saveHolidays(user,2026,new HolidaySave("x",List.of(new Holiday(2,29,"x"))))).hasMessageContaining("Feiertagsdatum");
assertThatThrownBy(()->service.saveHolidays(user,2026,new HolidaySave(AdminSupport.revision(holidays),List.of(new Holiday(10,12,"x"))))).hasMessageContaining("heute");
verify(db,never()).insert(anyString(),anyMap());
}
@Test void futureNamesAreUpdatedWithoutReplacingTimeWindows(){
holidays.add(fields("ph_id",2,"ph_month",12,"ph_day",25,"ph_name","alt","ph_time_from","10:00","ph_time_to","12:00"));
service.saveHolidays(user,2026,new HolidaySave(AdminSupport.revision(holidays),List.of(new Holiday(12,25,"neu"))));
verify(db).update("UPDATE publicholiday SET ph_name=? WHERE ph_id=? AND hq_id=?","neu",2L,7L);
verify(db,never()).insert(anyString(),anyMap());
}
@Test void multipleTimeWindowsRepresentOneDayAndSurviveAnUnchangedSave(){
holidays.add(fields("ph_id",2,"ph_month",12,"ph_day",25,"ph_name","vormittags","ph_time_from","10:00","ph_time_to","12:00"));
holidays.add(fields("ph_id",3,"ph_month",12,"ph_day",25,"ph_name","nachmittags","ph_time_from","14:00","ph_time_to","16:00"));
assertThat((List<?>)service.holidays(user,2026).get("days")).hasSize(1);
service.saveHolidays(user,2026,new HolidaySave(AdminSupport.revision(holidays),List.of(new Holiday(12,25,"vormittags"))));
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void classicCreatesOnlyThisHeadquartersOverride(){
service.saveTravelTime(user,TravelMode.CLASSIC,1,new TravelSave(AdminSupport.revision(Arrays.asList(1L,TravelMode.CLASSIC,null)),45));
verify(db).update("INSERT INTO serviceplztraveltime (srvp_id,hq_id,srvpt_traveltime) VALUES (?,?,?)",1L,7L,45);
verify(db,never()).update(startsWith("UPDATE serviceplz SET"),any(Object[].class));
}
@Test void bwvIsOneConditionalWriteAndReportsConcurrentUpdates(){
when(db.update(startsWith("UPDATE serviceplz SET"),any(Object[].class))).thenReturn(0);
assertThatThrownBy(()->service.saveTravelTime(user,TravelMode.BWV,1,new TravelSave(AdminSupport.revision(Arrays.asList(1L,TravelMode.BWV,30)),45))).hasMessageContaining("409");
verify(db).update("UPDATE serviceplz SET srvp_traveltime=? WHERE srvp_id=? AND srvp_valid=1 AND srvp_traveltime <=> ?",45,1L,30);
verify(db,never()).insert(anyString(),anyMap());
}
@Test void zeroAndEmptyTravelTimeUseTheConfiguredHeadquartersDefault() {
when(db.queryForList(startsWith("SELECT par_value FROM parameter"),eq(7L),eq("LATEST_TAKETIME_IN_MINUTES"))).thenReturn(List.of(fields("par_value","35")));
service.saveTravelTime(user,TravelMode.CLASSIC,1,new TravelSave(AdminSupport.revision(Arrays.asList(1L,TravelMode.CLASSIC,null)),0));
verify(db).update("INSERT INTO serviceplztraveltime (srvp_id,hq_id,srvpt_traveltime) VALUES (?,?,?)",1L,7L,35);
service.saveTravelTime(user,TravelMode.BWV,1,new TravelSave(AdminSupport.revision(Arrays.asList(1L,TravelMode.BWV,30)),null));
verify(db).update(startsWith("UPDATE serviceplz SET"),eq(35),eq(1L),eq(30));
}
@Test void missingDefaultNeverWritesAnInventedTravelTime() {
assertThatThrownBy(()->service.saveTravelTime(user,TravelMode.CLASSIC,1,new TravelSave("",0))).hasMessageContaining("LATEST_TAKETIME_IN_MINUTES");
verify(db,never()).update(anyString(),any(Object[].class));
}
}
@@ -0,0 +1,23 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import java.util.*;import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
public final class ContactIntegrationAssertions {
public static void verifyContactWrites(DatabaseService db) throws Exception {
var scope=mock(CrmScope.class);when(scope.checked(any(),any())).thenReturn(List.of(7L));var service=new ContactService(db,scope);var user=AppointmentServiceTest.user(0,11);String schema=(String)service.schema(user,7).get("revision");
try(var executor=Executors.newFixedThreadPool(2)){var calls=new ArrayList<Callable<Map<String,Object>>>();for(int i=0;i<2;i++){int index=i;calls.add(()->service.save(user,null,new ContactService.Save(7,null,schema,Map.of(1L,"Kontakt "+index,2L,"Notiz"))));}var result=executor.invokeAll(calls);assertThat(result.get(0).get().get("id")).isNotEqualTo(result.get(1).get().get("id"));}
assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue",Integer.class)).isEqualTo(4);assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvaluehistory",Integer.class)).isEqualTo(4);
var detail=service.get(user,7,1);service.save(user,1L,new ContactService.Save(7,(String)detail.get("revision"),schema,Map.of(1L,"Geändert",2L,"Notiz")));assertThatThrownBy(()->service.save(user,1L,new ContactService.Save(7,(String)detail.get("revision"),schema,Map.of(1L,"Veraltet")))).hasMessageContaining("409");
assertThatThrownBy(()->db.contactWriteTransaction(()->{db.update("DELETE FROM metafieldvalue WHERE mtfv_id=?",1);throw new IllegalStateException("rollback");})).hasMessage("rollback");assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue WHERE mtfv_id=1",Integer.class)).isEqualTo(2);
var updated=service.get(user,7,1);service.remove(user,7,1,(String)updated.get("revision"));assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue",Integer.class)).isEqualTo(2);assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvaluehistory",Integer.class)).isEqualTo(5);
// Exercise the shared category-scoped editor against the same real InnoDB locks.
db.writeTransaction(()->{db.update("INSERT INTO metafieldkey VALUES (1701,'Beginn','date_begin'),(1702,'Ende','date_end'),(1703,'Niederlassungen','checkboxes_hq')");db.update("INSERT INTO metafieldcategorykey VALUES (60,15,1701,0,1,1),(61,15,1702,0,2,1),(62,15,1703,0,3,0)");return null;});
var forms=mock(FormAdministration.class);var messages=mock(MessageAdministration.class);var actor=AppointmentServiceTest.user(0,8);when(messages.headquarters(actor)).thenReturn(List.of(OrderEntryService.fields("id",7L,"label","B")));var evaluations=new EvaluationService(db,forms,messages);String evalSchema=(String)evaluations.schema(actor,7).get("revision");
var saved=evaluations.save(actor,null,new MetafieldRecords.Save(7,null,evalSchema,Map.of(60L,"01.10.2026",61L,"31.10.2026",62L,"7")));long evalId=((Number)saved.get("id")).longValue();assertThat(evalId).isGreaterThan(2);var detailEval=evaluations.get(actor,7,evalId);assertThat((Map<?,?>)detailEval.get("values")).hasSize(3);
evaluations.save(actor,evalId,new MetafieldRecords.Save(7,(String)detailEval.get("revision"),evalSchema,Map.of(60L,"02.10.2026")));assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue WHERE mtfv_id=?",Integer.class,evalId)).isEqualTo(3);assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvaluehistory WHERE mtfv_id=?",Integer.class,evalId)).isEqualTo(3);
var updatedEval=evaluations.get(actor,7,evalId);evaluations.remove(actor,7,evalId,(String)updatedEval.get("revision"));assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue WHERE mtfv_id=?",Integer.class,evalId)).isZero();assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue",Integer.class)).isEqualTo(2);
}
}
@@ -0,0 +1,28 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import java.util.*;import java.util.function.Supplier;import java.time.*;import java.math.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryService.fields;
class CrmServiceTest {
final DatabaseService db=mock(DatabaseService.class);final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);final CrmScope scope=new CrmScope(db);final ContactService contacts=new ContactService(db,scope);final SalesAnalytics sales=new SalesAnalytics(db,scope,catalog,"phoenix_log");final LegacyUser user=AppointmentServiceTest.user(0,11),master=AppointmentServiceTest.user(0,10,11);
final List<Map<String,Object>> defs=List.of(fields("id",1L,"keyId",101,"label","Name","type","varchar","history",1),fields("id",2L,"keyId",104,"label","Geburtstag","type","birthdate","history",0),fields("id",3L,"keyId",300,"label","Kunde","type","co2cs","history",0));
List<Map<String,Object>> values=List.of(fields("mtfck_id",1L,"mtfv_id",44L,"mtfv_value","Alt"));final List<String> queries=new ArrayList<>(),writes=new ArrayList<>();final List<List<Object>> arguments=new ArrayList<>();List<Map<String,Object>> targetRows=List.of();boolean huge=false,brokenSchema=false;
@BeforeEach void setup(){when(db.readTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());when(db.writeTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());when(db.contactWriteTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());when(db.queryForObject(startsWith("SELECT COALESCE(MAX"),eq(Long.class))).thenReturn(99L);when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);queries.add(sql);arguments.add(Arrays.asList(c.getArguments()).subList(1,c.getArguments().length));if(sql.startsWith("SELECT DISTINCT h.hq_id"))return sql.contains(" AND h.hq_id=?")?List.of(fields("id",7L,"label","B")):List.of(fields("id",7L,"label","B"),fields("id",8L,"label","HH"));if(sql.startsWith("SELECT ck.mtfck_id"))return brokenSchema?List.of():defs;if(sql.startsWith("SELECT mtfck_id,mtfv_id"))return values;if(sql.startsWith("SELECT cs_id"))return List.of(fields("cs_id",9L));if(sql.startsWith("SELECT DISTINCT v.mtfv_id"))return huge?Collections.nCopies(501,fields("id",44L,"headquartersId",7L)):List.of();if(sql.startsWith("SELECT jb.hq_id"))return List.of(fields("id",7L,"jobs",2L,"amount",new BigDecimal("100.00")));if(sql.startsWith("SELECT l.usr_id"))return List.of(fields("userId",11L,"person","Test","headquarters","B","customerId",9L,"description","SRC=jb|OLD=1|NEW=0"),fields("userId",11L,"person","Test","headquarters","B","customerId",9L,"description","SRC=cs|OLD=|NEW=0"));if(sql.startsWith("SELECT cs.cs_id AS id"))return List.of(fields("id",9L,"amount",new BigDecimal("20.00")));if(sql.startsWith("SELECT par_id"))return targetRows;return List.of();});when(db.update(anyString(),any(Object[].class))).thenAnswer(c->{writes.add(c.getArgument(0));return 1;});when(db.lockRows(startsWith("SELECT hq_id"),any(Object[].class))).thenReturn(List.of(fields("hq_id",7L)));when(db.lockRows(startsWith("SELECT par_id"),any(Object[].class))).thenAnswer(c->targetRows);when(catalog.parameter(anyLong(),anyString())).thenReturn("");}
ContactService.Save save(String rev,Map<Long,String> v){return new ContactService.Save(7,rev,AdminSupport.revision(defs),v);}
@Test void scopeCannotExpandToOtherTenantOrMissingSalesRight(){for(var u:List.of(AppointmentServiceTest.user(0),AppointmentServiceTest.user(11)))assertThatThrownBy(()->contacts.schema(u,7)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);verifyNoInteractions(db);assertThatThrownBy(()->contacts.schema(user,8)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->contacts.schema(master,99)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);}
@Test void creationUsesLockedGlobalIdAndLegacyHistory(){assertThat(contacts.save(user,null,save(null,Map.of(1L,"Neu")))).containsEntry("id",99L);verify(db).contactWriteTransaction(any());assertThat(writes).filteredOn(s->s.startsWith("INSERT INTO metafieldvalue ")).hasSize(3);verify(db).update(startsWith("INSERT INTO metafieldvaluehistory"),eq(1L),eq(99L),eq("Neu"),any(LocalDateTime.class));}
@Test void staleValuesAndDefinitionChangesCannotDeleteExistingContact(){assertThatThrownBy(()->contacts.save(user,44L,save("stale",Map.of(1L,"Neu")))).hasMessageContaining("409");assertThatThrownBy(()->contacts.save(user,44L,new ContactService.Save(7,"x","stale",Map.of(1L,"Neu")))).hasMessageContaining("409");assertThat(writes).isEmpty();}
@Test void contactsRejectInvalidDatesUnknownFieldsAndOversizeUtf8(){for(var vals:List.of(Map.of(2L,"31.02.2026"),Map.of(999L,"unbekannt"),Map.of(1L,"€".repeat(22000)),Map.of(1L," ")))assertThatThrownBy(()->contacts.save(user,null,save(null,vals))).hasMessageContaining("400");assertThat(writes).isEmpty();}
@Test void updatesAndDeletesAreScopedToDefinitionIdsNotObjectIdAlone(){contacts.save(user,44L,save(AdminSupport.revision(values),Map.of(1L,"Neu")));contacts.remove(user,7,44,AdminSupport.revision(values));assertThat(writes).filteredOn(s->s.startsWith("DELETE")).hasSize(2).allMatch(s->s.contains("mtfv_id=? AND mtfck_id IN (?,?,?)"));}
@Test void historyRequiresAnAccessibleCurrentContact(){values=List.of();assertThatThrownBy(()->contacts.history(user,7,44)).hasMessageContaining("404");assertThat(queries).noneMatch(s->s.contains("FROM metafieldvaluehistory"));}
@Test void contactSearchBindsWildcardsAndCustomerScope(){contacts.list(user,new ContactService.Query(List.of(7L),"%_x'",false,"A",9L));assertThat(queries).anyMatch(s->s.contains("sv.mtfv_value LIKE ?")&&s.contains("cv.mtfv_value=?"));assertThat(arguments.stream().flatMap(Collection::stream)).contains("%\\%\\_x'%","A%","9");huge=true;assertThatThrownBy(()->contacts.list(user,new ContactService.Query(List.of(7L),"",false,"",null))).hasMessageContaining("500 Kontakte");}
@Test void statusCounterIncludesOnlyConversionsToCustomers(){var r=sales.switches(user,new SalesAnalytics.Query(LocalDate.of(2026,1,1),LocalDate.of(2026,10,13),List.of(7L)));assertThat((List<?>)r.get("conversions")).hasSize(1);assertThat(r.get("summary").toString()).contains("jb1=1");assertThat(queries).anyMatch(s->s.contains("l.hq_id IN (?) AND c.hq_id IN (?)"));assertThat(queries).anyMatch(s->s.contains("jb_status=2")&&s.contains("jb_storno IN (0,1,3)")&&s.contains("jb.hq_id=cs.hq_id"));}
@Test void statusParserAndPriorYearMatchLegacyLeapHandling(){assertThat(SalesAnalytics.description("SRC=jb|OLD=2|NEW=0")).containsEntry("NEW","0");assertThat(SalesAnalytics.previousYear(LocalDate.of(2024,2,29))).isEqualTo(LocalDate.of(2023,2,28));assertThat(SalesAnalytics.previousYear(LocalDate.of(2025,2,28))).isEqualTo(LocalDate.of(2024,2,29));}
@Test void motivationHandlesMissingTargetsAndEmptyBranchWithoutDividingByZero(){var r=sales.motivation(master,YearMonth.now().toString(),List.of(7L,8L));var rows=(List<Map<String,Object>>)r.get("items");assertThat(rows.getFirst()).containsEntry("actual",new BigDecimal("100.00")).containsEntry("percent",null);assertThat(rows.get(1)).containsEntry("actual",BigDecimal.ZERO).containsEntry("jobs",0L);assertThat(SalesAnalytics.percent(new BigDecimal("75"),new BigDecimal("100"))).isEqualByComparingTo("75");}
@Test void exclusionsAreBoundAndRelatedCustomerSettingIsHonored(){when(catalog.parameter(0,"MOTIVATIONCOUNTER_EXCLUDED_CS")).thenReturn("TEST-1, TEST-2'");sales.motivation(user,YearMonth.now().toString(),List.of(7L));assertThat(queries).anyMatch(s->s.contains("cs.cs_eid NOT IN (?,?)")&&!s.contains("TEST-2"));assertThat(arguments.stream().flatMap(Collection::stream)).contains("TEST-1","TEST-2'");when(catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER")).thenReturn(true);sales.switches(user,new SalesAnalytics.Query(LocalDate.now(),LocalDate.now(),List.of(7L)));assertThat(queries).anyMatch(s->s.contains("csc.csc_id=jb.csc_id_related"));}
@Test void targetsRequireConfiguredPasswordAndLockOutRepeatedFailures(){assertThatThrownBy(()->sales.targets(user,new SalesAnalytics.TargetAccess(7,"x"))).hasMessageContaining("kein Motivation");when(catalog.parameter(7,"MOTIVATIONCOUNTER_PASSWORD")).thenReturn("test-only");for(int i=0;i<5;i++)assertThatThrownBy(()->sales.targets(user,new SalesAnalytics.TargetAccess(7,"wrong"))).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->sales.targets(user,new SalesAnalytics.TargetAccess(7,"test-only"))).hasMessageContaining("fünf Minuten");assertThat(writes).isEmpty();}
@Test void targetEditingPreservesHistoryAndChecksRevision(){when(catalog.parameter(7,"MOTIVATIONCOUNTER_PASSWORD")).thenReturn("test-only");String m=YearMonth.now().toString();targetRows=List.of(fields("id",5L,"name","MOTIVATIONCOUNTER_"+m.replace('-','_'),"amount","100.00"));var t=new SalesAnalytics.Target(7,m,new BigDecimal("150.00"),"test-only",AdminSupport.revision(targetRows.getFirst()));assertThat(sales.targets(user,new SalesAnalytics.TargetAccess(7,"test-only")).getFirst()).containsEntry("month",m);sales.target(user,t);assertThat(writes).contains("UPDATE parameter SET par_value=? WHERE par_id=?");assertThatThrownBy(()->sales.target(user,new SalesAnalytics.Target(7,m,t.amount(),t.password(),"stale"))).hasMessageContaining("409");assertThatThrownBy(()->sales.target(user,new SalesAnalytics.Target(7,YearMonth.now().minusMonths(1).toString(),t.amount(),t.password(),t.revision()))).hasMessageContaining("vergangener");}
}
@@ -0,0 +1,45 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.LegacyMapping.*;
public class CustomerServicesIntegrationAssertions {
public static List<String> setup(){return List.of(
"DROP TABLE IF EXISTS customer,service,servicetype,customerservice,customerservicetype,parameter,genericdatacontainer",
"CREATE TABLE customer(cs_id INT PRIMARY KEY,hq_id INT) ENGINE=InnoDB",
"INSERT INTO customer VALUES(9,7),(10,8)",
"CREATE TABLE service(srv_id INT PRIMARY KEY,hq_id INT,cs_id INT,srv_name VARCHAR(100),srv_mode INT) ENGINE=InnoDB",
"INSERT INTO service VALUES(1,0,0,'PKW',1),(2,0,0,'Kombi',1),(3,8,0,'Fremd',1),(4,0,0,'Wartezeit',0)",
"CREATE TABLE servicetype(srvt_id INT PRIMARY KEY,hq_id INT,cs_id INT,srvt_name VARCHAR(100),srvt_mode INT) ENGINE=InnoDB",
"INSERT INTO servicetype VALUES(11,0,0,'Kilometer',1),(12,0,0,'Stopps',1)",
"CREATE TABLE customerservice(cs_id INT,srv_id INT,cssrv_sort INT) ENGINE=MyISAM",
"INSERT INTO customerservice VALUES(9,1,0),(10,3,0)",
"CREATE TABLE customerservicetype(cs_id INT,srvt_id INT,cssrvt_sort INT) ENGINE=MyISAM",
"INSERT INTO customerservicetype VALUES(9,11,0)",
"CREATE TABLE parameter(hq_id INT,emp_id INT,par_key VARCHAR(100),par_value VARCHAR(100)) ENGINE=InnoDB",
"CREATE TABLE genericdatacontainer(gdc_id INT PRIMARY KEY AUTO_INCREMENT,gdc_obj_type VARCHAR(10),gdc_obj_id INT,gdc_gen_fieldname VARCHAR(50),gdc_content MEDIUMTEXT,gdc_context TEXT) ENGINE=InnoDB"
);}
public static void verify(DatabaseService db)throws Exception{
var u=new LegacyUser(11,12,7,"QA","1".repeat(70));var support=new BusinessSupport(db,new ObjectMapper());var service=new CustomerServices(db,support,new OrderEntryCatalog(db));var initial=service.view(u,9);
assertThat((List<?>)initial.get("serviceOptions")).hasSize(3);
assertThatThrownBy(()->service.view(u,10)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
assertThatThrownBy(()->service.view(new LegacyUser(11,12,7,"QA","110"+"1".repeat(67)),9)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);
assertThatThrownBy(()->service.save(u,9,new CustomerServices.Save(UUID.randomUUID().toString(),text(initial,"revision"),List.of(3L),List.of(11L)))).hasMessageContaining("nicht freigegeben");
var input=new CustomerServices.Save(UUID.randomUUID().toString(),text(initial,"revision"),List.of(2L,1L,4L),List.of(12L,11L));
var failing=spy(db);doAnswer(c->{c.callRealMethod();throw new org.springframework.dao.DataAccessResourceFailureException("lost MyISAM insert acknowledgement");}).when(failing).update(startsWith("INSERT INTO customerservicetype"),any(Object[].class));
var interrupted=new CustomerServices(failing,new BusinessSupport(failing,new ObjectMapper()),new OrderEntryCatalog(failing));
assertThat(interrupted.save(u,9,input)).containsEntry("state","PENDING");
assertThat(db.queryForList("SELECT srvt_id FROM customerservicetype WHERE cs_id=9")).hasSize(1);
assertThat(service.view(u,9).get("operations")).asList().hasSize(1);
assertThat(service.resume(u,9,input.requestId())).containsEntry("state","DONE");assertThat(service.save(u,9,input)).containsEntry("state","DONE");
assertThat(service.view(u,9)).containsEntry("services",List.of(2L,1L,4L)).containsEntry("types",List.of(12L,11L));
assertThat(db.queryForObject("SELECT COUNT(*) FROM genericdatacontainer WHERE gdc_obj_type='ngaudit'",Integer.class)).isEqualTo(1);
assertThat(db.queryForObject("SELECT srv_id FROM customerservice WHERE cs_id=10",Long.class)).isEqualTo(3L);
assertThatThrownBy(()->service.save(u,9,new CustomerServices.Save(UUID.randomUUID().toString(),text(initial,"revision"),List.of(1L),List.of(11L)))).hasMessageContaining("409");
assertThat(CustomerServices.recoverable(List.of(new CustomerServices.Assignment(4,0)),List.of(new CustomerServices.Assignment(1,0)),List.of(2L,1L))).isFalse();
support.json.writeValue(java.nio.file.Path.of("target/customer-services-ui-fixture.json").toFile(),service.view(u,9));
}
}
@@ -0,0 +1,25 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import org.springframework.security.access.AccessDeniedException;
import java.util.*;
import java.util.function.Supplier;
import static de.stadtbote.votian.OrderEntryService.fields;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class EvaluationServiceTest {
final DatabaseService db=mock(DatabaseService.class);final FormAdministration forms=mock(FormAdministration.class);final MessageAdministration messages=mock(MessageAdministration.class);final EvaluationService service=new EvaluationService(db,forms,messages);final LegacyUser user=AppointmentServiceTest.user(0,1,8);
final List<Map<String,Object>> defs=List.of(fields("id",60L,"keyId",1701L,"type","date_begin","label","Beginn","history",1),fields("id",61L,"keyId",1702L,"type","date_end","label","Ende","history",0),fields("id",62L,"keyId",1703L,"type","checkboxes_hq","label","Niederlassungen","history",0));
final List<Map<String,Object>> raw=List.of(fields("mtfck_id",60L,"mtfv_id",12L,"mtfv_value","01.10.2026"),fields("mtfck_id",61L,"mtfv_id",12L,"mtfv_value","31.10.2026"),fields("mtfck_id",62L,"mtfv_id",12L,"mtfv_value","7"));
@BeforeEach void setup(){when(messages.headquarters(user)).thenReturn(List.of(fields("id",7L,"label","B")));when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);if(sql.equals(MetafieldRecords.definitionsSql(15)))return defs;if(sql.startsWith("SELECT mtfck_id,mtfv_id"))return raw;return List.of();});when(db.contactWriteTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());when(db.queryForObject(anyString(),eq(Long.class),any(Object[].class))).thenReturn(13L);}
MetafieldRecords.Save draft(Map<Long,String> values){return new MetafieldRecords.Save(7,AdminSupport.revision(raw),AdminSupport.revision(defs),values);}
@Test void evaluationRequiresBothCustomerAndStatisticsRights(){assertThatThrownBy(()->service.schema(AppointmentServiceTest.user(0),7)).isInstanceOf(AccessDeniedException.class);assertThatThrownBy(()->service.schema(AppointmentServiceTest.user(8),7)).isInstanceOf(AccessDeniedException.class);}
@Test void globalCategoryHasIndependentBindings(){assertThat(service.schema(user,7)).containsEntry("headquarters",0L);verify(forms).fieldHeadquarters(user,15);verify(db).queryForList(MetafieldRecords.definitionsSql(15),0L);}
@Test void datesRemainPhpCompatibleAndValuesAreWrittenUnderMetafieldLock(){service.save(user,12L,draft(Map.of(60L,"02.10.2026")));verify(db).contactWriteTransaction(any());verify(db).update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",60L,12L,"02.10.2026");verify(db).update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",62L,12L,"7");}
@Test void invalidDatesAndReverseRangeAreRejectedBeforeWriting(){assertThatThrownBy(()->service.save(user,12L,draft(Map.of(60L,"31.02.2026")))).hasMessageContaining("Datum");assertThatThrownBy(()->service.save(user,12L,draft(Map.of(60L,"01.11.2026")))).hasMessageContaining("Enddatum");verify(db,never()).contactWriteTransaction(any());}
@Test void branchSelectionCannotExpandScope(){assertThatThrownBy(()->service.save(user,12L,draft(Map.of(62L,"7,8")))).hasMessageContaining("freigegeben");assertThatThrownBy(()->service.lookup(user,8,"customer","")).isInstanceOf(AccessDeniedException.class);verify(db,never()).contactWriteTransaction(any());}
@Test void foreignOrUnknownLinkedObjectsCannotBeSaved(){assertThatThrownBy(()->service.validateValue(user,7,fields("type","button:cs:cs_eid"),"OTHER")).hasMessageContaining("404");assertThatThrownBy(()->service.validateValue(user,7,fields("type","select:crvh:crvh_sid"),"OTHER")).hasMessageContaining("nicht gefunden");}
@Test void lookupUsesSessionScopeAndEscapesWildcards(){service.lookup(user,7,"customer","10%_");verify(db).queryForList(contains("WHERE cs.hq_id=?"),eq(7L),eq("%10\\%\\_%"),eq("%10\\%\\_%"));}
@Test void deletionNeverTouchesOtherCategoriesOrHistory(){service.remove(user,7,12,AdminSupport.revision(raw));verify(db).update("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN (?,?,?)",12L,60L,61L,62L);verify(db,never()).update(startsWith("DELETE FROM metafieldvaluehistory"),any(Object[].class));}
}
@@ -0,0 +1,14 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.AccessDeniedException;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.*;
class FilterAdministrationTest {
final DatabaseService db=mock(DatabaseService.class);final FilterAdministration service=new FilterAdministration(db,new ObjectMapper(),"phoenix_log");final LegacyUser admin=AppointmentServiceTest.user(0,32);
@Test void readAndWriteRightsAreIndependent(){assertThatThrownBy(()->service.options(AppointmentServiceTest.user(32))).isInstanceOf(AccessDeniedException.class);assertThatThrownBy(()->service.save(AppointmentServiceTest.user(0),null,null)).isInstanceOf(AccessDeniedException.class);assertThatThrownBy(()->service.resume(AppointmentServiceTest.user(0),UUID.randomUUID().toString())).isInstanceOf(AccessDeniedException.class);verifyNoInteractions(db);}
@Test void invalidTokenAndCodesNeverReachDatabase(){assertThatThrownBy(()->service.save(admin,null,new FilterAdministration.Save("wrong","",0,"OK","Test",1,0,""))).hasMessageContaining("Vorgangskennung");assertThatThrownBy(()->service.save(admin,null,new FilterAdministration.Save(UUID.randomUUID().toString(),"",0,"A,B","Test",1,0,""))).hasMessageContaining("Kürzel");verifyNoInteractions(db);}
@Test void invalidClassificationAndUtf8OverflowAreRejected(){assertThatThrownBy(()->service.save(admin,null,new FilterAdministration.Save(UUID.randomUUID().toString(),"",2,"OK","Test",1,0,""))).hasMessageContaining("Filterart");assertThatThrownBy(()->service.save(admin,null,new FilterAdministration.Save(UUID.randomUUID().toString(),"",0,"OK","Test",1,0,"😀".repeat(8000)))).hasMessageContaining("Langtext");verifyNoInteractions(db);}
}
@@ -0,0 +1,25 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
public class FilterIntegrationAssertions {
static FilterAdministration.Save draft(String token,String revision,String code,String name,int sort,String text){return new FilterAdministration.Save(token,revision,0,code,name,1,sort,text);}
public static void verify(DatabaseService db)throws Exception{
var json=new ObjectMapper();var user=AppointmentServiceTest.user(0,32);var service=new FilterAdministration(db,json,"crm_qa");String token=UUID.randomUUID().toString();var save=draft(token,"","NGT","Neuer Testfilter",8,"Kurier's Hinweis");
assertThat(service.save(user,null,save)).containsEntry("state","DONE");assertThat(service.save(user,null,save)).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT COUNT(*) FROM courierfilter",Integer.class)).isEqualTo(1);assertThat(db.queryForObject("SELECT COUNT(*) FROM log",Integer.class)).isEqualTo(1);
var row=service.rows().getFirst();long id=LegacyMapping.id(row,"crf_id");assertThat(LegacyMapping.text(row,"crf_longtext")).isEqualTo("Kurier's Hinweis");
assertThat(service.save(user,id,draft(UUID.randomUUID().toString(),AdminSupport.revision(row),"NGT","Neuer Testfilter",17,"Neuer Text"))).containsEntry("state","DONE");assertThat(service.row(id)).containsEntry("crf_sort",17).containsEntry("crf_longtext","Neuer Text");
assertThatThrownBy(()->service.save(user,id,draft(UUID.randomUUID().toString(),AdminSupport.revision(row),"NGT","Neuer Testfilter",18,"Veraltet"))).hasMessageContaining("409");
var failing=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("simulated audit failure")).when(failing).queryForList(startsWith("SELECT log_id"),any(Object[].class));var interrupted=new FilterAdministration(failing,json,"crm_qa");
String retry=UUID.randomUUID().toString();assertThat(interrupted.save(user,null,draft(retry,"","REP","Wiederaufnehmen",1,"Text"))).containsEntry("state","PENDING");assertThat(db.queryForObject("SELECT COUNT(*) FROM courierfilter WHERE crf_short='REP'",Integer.class)).isEqualTo(1);assertThat(service.resume(user,retry)).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT COUNT(*) FROM courierfilter WHERE crf_short='REP'",Integer.class)).isEqualTo(1);
db.writeTransaction(()->{db.update("INSERT INTO customer VALUES(1,1,',NGT,KEEP,','2020-01-01'),(2,2,',NGT,','2020-01-01')");db.update("INSERT INTO courier VALUES(1,1,',NGT,','2020-01-01'),(2,2,',NGT,','2020-01-01')");db.update("INSERT INTO couriervehicle VALUES(1,',NGT,KEEP,','2020-01-01')");return null;});
String deletion=UUID.randomUUID().toString();assertThat(interrupted.remove(user,id,new FilterAdministration.Delete(deletion,AdminSupport.revision(service.row(id))))).containsEntry("state","PENDING");assertThat(db.queryForObject("SELECT COUNT(*) FROM courierfilter WHERE crf_id=?",Integer.class,id)).isZero();assertThat(db.queryForObject("SELECT cs_filter FROM customer WHERE cs_id=1",String.class)).isEqualTo(",NGT,KEEP,");
assertThat(service.resume(user,deletion)).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT cs_filter FROM customer WHERE cs_id=1",String.class)).isEqualTo(",KEEP,");assertThat(db.queryForObject("SELECT cs_filter FROM customer WHERE cs_id=2",String.class)).isEqualTo(",NGT,");assertThat(db.queryForObject("SELECT cr_filter FROM courier WHERE cr_id=1",String.class)).isEmpty();assertThat(db.queryForObject("SELECT crvh_filter FROM couriervehicle WHERE crvh_id=1",String.class)).isEqualTo(",KEEP,");assertThat(db.queryForObject("SELECT YEAR(cs_modify) FROM customer WHERE cs_id=1",Integer.class)).isEqualTo(2020);
assertThat(service.resume(user,deletion)).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT par_value FROM parameter WHERE par_key='FILTER_TO_BE_DELETED'",String.class)).isEqualTo(",NGT,");assertThatThrownBy(()->service.save(user,null,draft(UUID.randomUUID().toString(),"","NGT","Neuanlage gesperrt",1,""))).hasMessageContaining("gesperrt");
db.writeTransaction(()->{db.update("INSERT INTO courierfilter (crf_type,crf_short,crf_text,crf_status,vht_id,crf_sort,crf_longtext) VALUES(0,'KF','Kühlfahrzeug',1,0,1,''),(0,'KF','Koffer',1,0,2,'')");db.update("UPDATE customer SET cs_filter=',KF,' WHERE cs_id=1");return null;});
var duplicate=db.queryForList("SELECT "+FilterAdministration.COLUMNS+" FROM courierfilter WHERE crf_short='KF' ORDER BY crf_id").getFirst();assertThat(service.remove(user,LegacyMapping.id(duplicate,"crf_id"),new FilterAdministration.Delete(UUID.randomUUID().toString(),AdminSupport.revision(duplicate)))).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT cs_filter FROM customer WHERE cs_id=1",String.class)).isEqualTo(",KF,");
}
}
@@ -0,0 +1,39 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import org.springframework.security.access.AccessDeniedException;
import java.util.*;
import java.util.function.Supplier;
import static de.stadtbote.votian.OrderEntryService.fields;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class FormAdministrationTest {
final DatabaseService db=mock(DatabaseService.class);final FormAdministration service=new FormAdministration(db);final LegacyUser user=AppointmentServiceTest.user(15);
final Map<String,Object> category=fields("mtfc_id",10L,"mtfc_mnemonic","newsletter","mtfc_description","Newsletter","mtfc_group",",24,99,","mtft_id",100L);
final Map<String,Object> template=fields("mtft_id",100L,"mtfc_id",0L,"mtft_name","Newsletter","mtft_group",",24,99,","mtft_content","<p>[-501,3,100-]</p>\n");
List<Map<String,Object>> keys=new ArrayList<>(List.of(fields("mtfk_id",501L,"mtfk_name","Titel","mtfk_type","varchar"),fields("mtfk_id",502L,"mtfk_name","Text","mtfk_type","text"),fields("mtfk_id",503L,"mtfk_name","Zusatz","mtfk_type","text")));
List<Map<String,Object>> bindings=List.of(fields("id",40L,"keyId",501L,"sort",1,"history",1,"name","Titel","type","varchar"),fields("id",41L,"keyId",502L,"sort",2,"history",0,"name","Text","type","text"));
final List<String> writes=new ArrayList<>();final List<Map<String,Object>> inserted=new ArrayList<>();boolean duplicate;String specific="",general="";
@BeforeEach void setup(){
when(db.readTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());when(db.writeTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);if(sql.startsWith("SELECT par_value")){String k=c.getArgument(1);return List.of(fields("par_value",k.endsWith("_10")?specific:general));}if(sql.contains("WHERE mtfc_id<>?")||sql.contains("WHERE mtfk_id<>?")||sql.contains("WHERE mtft_id<>?"))return duplicate?List.of(fields("id",999)):List.of();if(sql.equals(FormAdministration.BINDING))return bindings;if(sql.startsWith(FormAdministration.CATEGORY))return List.of(category);if(sql.startsWith(FormAdministration.TEMPLATE))return List.of(template);if(sql.startsWith(FormAdministration.KEY)||sql.equals("SELECT mtfk_id FROM metafieldkey"))return keys;if(sql.startsWith("SELECT md_id"))return List.of(fields("md_id",1L));if(sql.startsWith("SELECT grp_id"))return List.of(fields("id",24L,"label","Eigene Gruppe"));if(sql.startsWith("SELECT mtft_id"))return List.of(fields("mtft_id",100L));return List.of();});
when(db.lockRows(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);if(sql.startsWith(FormAdministration.CATEGORY))return List.of(category);if(sql.startsWith(FormAdministration.KEY))return keys;if(sql.startsWith(FormAdministration.TEMPLATE))return List.of(template);return List.of();});
when(db.update(anyString(),any(Object[].class))).thenAnswer(c->{writes.add(c.getArgument(0));return 1;});when(db.insert(anyString(),anyMap())).thenAnswer(c->{inserted.add(c.getArgument(1));return 900L;});
}
@Test void everyMutationRequiresFormRightBeforeDatabase(){var denied=AppointmentServiceTest.user(4);assertThatThrownBy(()->service.catalog(denied)).isInstanceOf(AccessDeniedException.class);assertThatThrownBy(()->service.saveBindings(denied,10,new FormAdministration.BindingSave("",List.of()))).isInstanceOf(AccessDeniedException.class);assertThatThrownBy(()->service.saveTemplate(denied,0,new FormAdministration.TemplateSave(null,"x","",List.of()))).isInstanceOf(AccessDeniedException.class);verifyNoInteractions(db);}
@Test void globalAndSpecificLegacyFlagsChooseFieldScope(){assertThat(service.fieldHeadquarters(user,10)).isZero();specific="1";assertThat(service.fieldHeadquarters(user,10)).isEqualTo(7);specific="0";general="1";assertThat(service.fieldHeadquarters(user,10)).isEqualTo(7);general="0";assertThat(service.fieldHeadquarters(user,10)).isZero();}
@Test void categoryRetainsUnmanageableGroupMemberships(){service.saveCategory(user,10,new FormAdministration.CategorySave(AdminSupport.revision(category),"newsletter","Neuer Name",100,List.of()));verify(db).update("UPDATE metafieldcategory SET mtfc_mnemonic=?,mtfc_description=?,mtfc_group=?,mtft_id=? WHERE mtfc_id=?","newsletter","Neuer Name",",99,",100L,10L);}
@Test void unknownGroupCannotBeAdded(){assertThatThrownBy(()->service.saveCategory(user,0,new FormAdministration.CategorySave(null,"new","Neu",0,List.of(88L)))).hasMessageContaining("Gruppe nicht freigegeben");assertThat(inserted).isEmpty();}
@Test void staleMetadataAndDuplicateNamesDoNotWrite(){assertThatThrownBy(()->service.saveCategory(user,10,new FormAdministration.CategorySave("stale","n","N",0,List.of()))).hasMessageContaining("409");duplicate=true;assertThatThrownBy(()->service.saveField(user,0,new FormAdministration.FieldSave(null,"Titel","varchar"))).hasMessageContaining("bereits vergeben");assertThat(writes).isEmpty();assertThat(inserted).isEmpty();}
@Test void templateKeepsExactWhitespaceAndPlaceholderSyntax(){String content=" <REMOVE_EMPTY_24>\n[-501,3,100-]\n</REMOVE_EMPTY_24>\n";service.saveTemplate(user,100,new FormAdministration.TemplateSave(AdminSupport.revision(template),"Newsletter",content,List.of(24L)));verify(db).update("UPDATE metafieldtemplate SET mtft_name=?,mtft_group=?,mtft_content=? WHERE mtft_id=?","Newsletter",",24,99,",content,100L);}
@Test void templateLimitMeasuresBytesInsteadOfCharacters(){assertThatThrownBy(()->service.saveTemplate(user,0,new FormAdministration.TemplateSave(null,"Neu","ä".repeat(32768),List.of()))).hasMessageContaining("65.535");assertThat(inserted).isEmpty();}
@Test void arbitraryTypesAndInvalidReferencesCannotEnterMetadata(){for(String type:List.of("sql:DELETE FROM customer","ref:999","ref:501:501000000000000","ref:0"))assertThatThrownBy(()->service.saveField(user,0,new FormAdministration.FieldSave(null,"Neu",type))).isInstanceOf(RuntimeException.class);assertThat(inserted).isEmpty();}
@Test void referencesCannotCreateCyclesAcrossExistingFields(){keys.set(1,fields("mtfk_id",502L,"mtfk_name","Text","mtfk_type","ref:501"));assertThatThrownBy(()->service.saveField(user,501,new FormAdministration.FieldSave(AdminSupport.revision(keys.getFirst()),"Titel","ref:502"))).hasMessageContaining("Zyklische");assertThat(writes).isEmpty();}
@Test void existingDuplicateTemplateNameAndLegacyReferenceTypeDoNotBlockContentEdits(){duplicate=true;service.saveTemplate(user,100,new FormAdministration.TemplateSave(AdminSupport.revision(template),"Newsletter","Neuer Inhalt",List.of(24L)));assertThat(writes).hasSize(1);duplicate=false;keys.set(0,fields("mtfk_id",501L,"mtfk_name","Alt","mtfk_type","ref:desc:xxx:yyy:..."));service.saveField(user,501,new FormAdministration.FieldSave(AdminSupport.revision(keys.getFirst()),"Umbenannt","ref:desc:xxx:yyy:..."));assertThat(writes).hasSize(2);}
@Test void knownMappedFieldsAndExistingCustomTypesArePreserved(){service.saveField(user,0,new FormAdministration.FieldSave(null,"Neu","varchar"));assertThat(inserted.getFirst()).containsEntry("mtfk_type","varchar");}
String bindingRevision(long hq){return AdminSupport.revision(Arrays.asList(hq,bindings));}
@Test void reorderUpdatesOnlySortAndNewFieldKeepsSameBranch(){specific="1";service.saveBindings(user,10,new FormAdministration.BindingSave(bindingRevision(7),List.of(502L,501L,503L)));verify(db).update("UPDATE metafieldcategorykey SET mtfck_sort=? WHERE mtfck_id=? AND hq_id=? AND mtfc_id=?",1,41L,7L,10L);verify(db).update("UPDATE metafieldcategorykey SET mtfck_sort=? WHERE mtfck_id=? AND hq_id=? AND mtfc_id=?",2,40L,7L,10L);assertThat(inserted).singleElement().satisfies(r->assertThat(r).containsEntry("hq_id",7L).containsEntry("mtfk_id",503L).containsEntry("mtfck_history",0));assertThat(writes).allMatch(s->!s.contains("DELETE")&&!s.contains("metafieldvalue"));}
@Test void deletionDuplicateAndUnknownAssignmentsCannotOrphanValues(){for(List<Long> selection:List.of(List.of(501L),List.of(501L,501L),List.of(501L,502L,999L)))assertThatThrownBy(()->service.saveBindings(user,10,new FormAdministration.BindingSave(bindingRevision(0),selection))).isInstanceOf(RuntimeException.class);assertThat(writes).isEmpty();assertThat(inserted).isEmpty();}
@Test void concurrentScopeOrSchemaChangeRequiresReload(){specific="1";assertThatThrownBy(()->service.saveBindings(user,10,new FormAdministration.BindingSave(bindingRevision(0),List.of(501L,502L)))).hasMessageContaining("409");assertThat(writes).isEmpty();}
}
@@ -0,0 +1,70 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import org.springframework.security.access.AccessDeniedException;
import java.util.*;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryService.fields;
import static de.stadtbote.votian.GroupAdministration.*;
class GroupAdministrationTest {
final DatabaseService db=mock(DatabaseService.class);
final GroupAdministration service=new GroupAdministration(db);
final LegacyUser user=new LegacyUser(11,12,7,"test","1".repeat(70));
Map<String,Object> group=fields("grp_id",5,"md_id",1,"hq_id",7,"grp_name","Test","grp_readonly",0,"grp_system",0,"grp_group","");
List<Map<String,Object>> graph=List.of();boolean related=false;
@BeforeEach void setup(){
when(db.readTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.writeTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.lockRows(anyString(),any(Object[].class))).thenAnswer(c->((String)c.getArgument(0)).startsWith("SELECT grp_id,md_id")?List.of(group):List.of(fields("md_id",1)));
when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{
String sql=c.getArgument(0);
if(sql.startsWith("SELECT md_id"))return List.of(fields("md_id",1));
if(sql.startsWith("SELECT grp_id,md_id"))return List.of(group);
if(sql.startsWith("SELECT o."))return List.of(fields("id",9L,"memberships",",8,"));
if(sql.startsWith("SELECT grp_id,grp_group"))return graph;
if(sql.contains(" LIKE ? LIMIT 1"))return related?List.of(fields("id",2)):List.of();
return List.of();
});
when(db.update(anyString(),any(Object[].class))).thenReturn(1);
}
@Test void groupAdministrationNeedsBothLegacyRights(){
String bits="1".repeat(25)+"0"+"1".repeat(44);
assertThatThrownBy(()->service.list(new LegacyUser(1,2,7,"test",bits))).isInstanceOf(AccessDeniedException.class);
verifyNoInteractions(db);
}
@Test void protectedGroupCannotBeRenamedOrHaveItsMembershipChanged(){
group.put("grp_readonly",1);
assertThatThrownBy(()->service.save(user,5,new GroupSave(AdminSupport.revision(group),"Neu",false))).hasMessageContaining("Systemgruppe");
assertThatThrownBy(()->service.membership(user,5,Kind.CUSTOMER,9,new MembershipSave("x",true))).hasMessageContaining("Systemgruppe");
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void membershipPreservesOtherGroupsAndUsesCompareAndSet(){
service.membership(user,5,Kind.CUSTOMER,9,new MembershipSave(AdminSupport.revision(Arrays.asList(9L,",8,")),true));
verify(db).update("UPDATE customer SET cs_group=? WHERE cs_id=? AND cs_group <=> ?",",8,5,",9L,",8,");
verify(db).queryForList(contains("WHERE o.hq_id=? AND o.cs_id=?"),eq(7L),eq(9L));
}
@Test void staleMembershipCannotOverwriteAnAssignment(){
assertThatThrownBy(()->service.membership(user,5,Kind.COURIER,9,new MembershipSave("stale",false))).hasMessageContaining("409");
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void nestedGroupsCannotBecomeCyclic(){
graph=List.of(fields("grp_id",5,"grp_group",",6,"),fields("grp_id",6,"grp_group",",9,"));
assertThatThrownBy(()->service.membership(user,5,Kind.GROUP,9,new MembershipSave(AdminSupport.revision(Arrays.asList(9L,",8,")),true))).hasMessageContaining("indirekt");
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void nonemptyGroupsCannotBeDeletedLeavingDanglingMemberships(){
related=true;assertThatThrownBy(()->service.remove(user,5,AdminSupport.revision(group))).hasMessageContaining("noch Mitglieder");
verify(db,never()).update(anyString(),any(Object[].class));
related=false;service.remove(user,5,AdminSupport.revision(group));
verify(db).update("DELETE FROM groups WHERE grp_id=? AND md_id=?",5L,1L);
}
@Test void sharedGroupsNeedMasterRights(){
String bits="1".repeat(10)+"0"+"1".repeat(59);
var limited=new LegacyUser(11,12,7,"test",bits);group.put("hq_id",0);
assertThatThrownBy(()->service.save(limited,5,new GroupSave(AdminSupport.revision(group),"Neu",false))).isInstanceOf(AccessDeniedException.class);
verify(db,never()).update(anyString(),any(Object[].class));
}
}
@@ -0,0 +1,42 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseService;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.LegacyMapping.*;
public class GroupDeletionIntegrationAssertions {
public static List<String> setup(){var setup=new ArrayList<String>();
for(String table:List.of("groups","customer","courier","couriervehicle","employee","article","service","servicetype","branch","metafieldcategory","metafieldtemplate","messagegroup","genericdatacontainer","mandator","mandatorheadquarters","headquarters","user"))setup.add("DROP TABLE IF EXISTS "+table);
setup.add("CREATE TABLE groups (grp_id INT PRIMARY KEY,md_id INT,hq_id INT,grp_name VARCHAR(100),grp_readonly INT DEFAULT 0,grp_system INT DEFAULT 0,grp_group VARCHAR(255) DEFAULT '') ENGINE=InnoDB");
setup.add("CREATE TABLE headquarters(hq_id INT PRIMARY KEY,hq_mnemonic VARCHAR(30)) ENGINE=InnoDB");setup.add("INSERT INTO headquarters VALUES(7,'Berlin'),(8,'Hamburg'),(9,'Fremd')");
setup.add("CREATE TABLE mandator(md_id INT PRIMARY KEY) ENGINE=InnoDB");setup.add("INSERT INTO mandator VALUES(1),(2)");
setup.add("CREATE TABLE mandatorheadquarters(md_id INT,hq_id INT) ENGINE=InnoDB");setup.add("INSERT INTO mandatorheadquarters VALUES(1,7),(1,8),(2,9)");
setup.add("CREATE TABLE user(usr_id INT PRIMARY KEY,hq_id INT) ENGINE=InnoDB");
for(var k:GroupAdministration.Kind.values())if(k!=GroupAdministration.Kind.GROUP)setup.add("CREATE TABLE "+k.table+" ("+k.id()+" INT PRIMARY KEY,"+k.group()+" VARCHAR(255),hq_id INT,usr_id INT"+(k==GroupAdministration.Kind.VEHICLE?",cr_id INT":"")+") ENGINE="+(k==GroupAdministration.Kind.BRANCH?"MyISAM":"InnoDB"));
setup.add("CREATE TABLE messagegroup(msggrp_id INT PRIMARY KEY,hq_id INT,msggrp_group VARCHAR(255)) ENGINE=InnoDB");
setup.add("CREATE TABLE genericdatacontainer(gdc_id INT AUTO_INCREMENT PRIMARY KEY,gdc_obj_type VARCHAR(10),gdc_obj_id INT,gdc_gen_fieldname VARCHAR(50),gdc_content MEDIUMTEXT,gdc_context TEXT) ENGINE=InnoDB");
setup.add("INSERT INTO groups(grp_id,md_id,hq_id,grp_name) VALUES(5,1,7,'Löschen'),(6,1,8,'Hamburg'),(7,2,9,'Fremd')");
setup.add("INSERT INTO customer VALUES(1,',5,50,',8,0)");setup.add("INSERT INTO branch VALUES(1,',5,8,',0,0)");setup.add("INSERT INTO messagegroup VALUES(1,7,'5,50')");
return setup;}
public static void verify(DatabaseService db) throws Exception {
var json=new ObjectMapper();var groups=new GroupAdministration(db);var support=new BusinessSupport(db,json);var deletion=new GroupDeletion(db,groups,json,support);var user=new LegacyUser(11,12,7,"qa","1".repeat(70));
assertThat(groups.scoped(user,8L).headquartersId()).isEqualTo(8);assertThatThrownBy(()->groups.scoped(user,9L)).hasMessageContaining("Mandanten");
assertThatThrownBy(()->groups.scoped(new LegacyUser(11,12,7,"qa","1".repeat(10)+"0"+"1".repeat(59)),8L)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);
var preview=deletion.preview(user,5);assertThat(preview).containsEntry("total",3);
var failing=spy(db);doAnswer(c->{c.callRealMethod();throw new org.springframework.dao.DataAccessResourceFailureException("lost MyISAM acknowledgement");}).when(failing).update(startsWith("UPDATE branch SET"),any(Object[].class));
var interrupted=new GroupDeletion(failing,new GroupAdministration(failing),json,new BusinessSupport(failing,json));String token=UUID.randomUUID().toString();
var command=new GroupDeletion.Delete(token,text(preview,"revision"));assertThat(interrupted.remove(user,5,command)).containsEntry("state","PENDING");
assertThat(db.queryForObject("SELECT br_group FROM branch WHERE br_id=1",String.class)).isEqualTo(",8,");
assertThat(db.queryForObject("SELECT cs_group FROM customer WHERE cs_id=1",String.class)).isEqualTo(",5,50,");
assertThat(deletion.resume(user,token)).containsEntry("state","DONE");assertThat(deletion.remove(user,5,command)).containsEntry("state","DONE");
assertThat(db.queryForObject("SELECT cs_group FROM customer WHERE cs_id=1",String.class)).isEqualTo(",50,");assertThat(db.queryForObject("SELECT msggrp_group FROM messagegroup WHERE msggrp_id=1",String.class)).isEqualTo("50");
assertThat(db.queryForObject("SELECT COUNT(*) FROM groups WHERE grp_id=5",Integer.class)).isZero();assertThat(db.queryForObject("SELECT COUNT(*) FROM customer",Integer.class)).isEqualTo(1);
var h=groups.scoped(user,8L);var next=deletion.preview(h,6);db.writeTransaction(()->db.update("UPDATE customer SET cs_group=',6,' WHERE cs_id=1"));
assertThatThrownBy(()->deletion.remove(h,6,new GroupDeletion.Delete(UUID.randomUUID().toString(),text(next,"revision")))).hasMessageContaining("409");
db.writeTransaction(()->db.update("UPDATE customer SET hq_id=9 WHERE cs_id=1"));assertThatThrownBy(()->deletion.preview(h,6)).hasMessageContaining("außerhalb");
}
}
@@ -0,0 +1,72 @@
package de.stadtbote.votian;
import org.junit.jupiter.api.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import static de.stadtbote.votian.OrderEntryFixtures.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.LegacyOrderPricing.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class LegacyDistancePricingTest {
final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);
final LegacyOrderPricing tariffs=mock(LegacyOrderPricing.class);
final LegacyDistancePricing pricing=new LegacyDistancePricing(catalog,tariffs);
final Map<String,Tariff> rates=new HashMap<>();
@BeforeEach void setup() {
when(catalog.parameter(anyLong(),anyString())).thenReturn("");
when(tariffs.service(anyLong(),anyLong(),anyString(),anyString(),any(),any())).thenAnswer(call -> rates.getOrDefault(call.getArgument(3),rate("0","0",false)));
}
static Tariff rate(String customer,String courier,boolean found){return new Tariff(new BigDecimal(customer),BigDecimal.ZERO,new BigDecimal(courier),found);}
OrderRoutingService.Result route(boolean longhaul,String body) throws Exception {
return new OrderRoutingService.Result(new RouteInfo(new BigDecimal("10.01"),new BigDecimal("10.01"),600,7,"HH",7,longhaul,longhaul,List.of()),JSON.readTree(body));
}
LegacyDistancePricing.Price price(Request request,OrderRoutingService.Result route) {
return pricing.calculate(request,route,31,"PKW",LocalDateTime.parse("2026-10-12T10:00:00"),BigDecimal.ZERO,new BigDecimal("30"));
}
@Test void kmCalculationIncludesAdditionalStopsAndRoundsCustomerUpOnly() throws Exception {
rates.put("km-Preis",rate("1.23","0",true));rates.put("Grundpreis",rate("5","0",true));rates.put("Weitere<br>Adressen",rate("3","0",true));
var input=with(request(),"pricing.mode","KILOMETER");var stops=new ArrayList<>(input.stops());stops.add(stops.getLast());input=with(input,"stops",stops);
var result=price(input,route(false,"{}"));
assertThat(result.base()).isEqualByComparingTo("21"); // 10.01 * 1.23 + 5 + 3 = 20.31 -> 21
assertThat(result.courier()).isEqualByComparingTo("14.22");
assertThat(result.lines()).extracting(CalculationLine::name).containsExactly("Kilometer","Grundpreis","Weitere Adressen","Aufrundung");
assertThat(LegacyDistancePricing.legacyType(input,route(false,"{}"),result)).isEqualTo("10.01|1.23|5");
}
@Test void minimumAndNoBasicPriceAreTakenFromLegacyParameters() throws Exception {
rates.put("km-Preis",rate("1","0",true));rates.put("Grundpreis",rate("50","0",true));rates.put("Mindestpreis",rate("30.20","24",true));
when(catalog.enabled(7,"MASK_NO_BASIC_PRICE_FOR_KM_PRICE")).thenReturn(true);
var result=price(with(request(),"pricing.mode","KILOMETER"),route(false,"{}"));
assertThat(result.base()).isEqualByComparingTo("31");assertThat(result.courier()).isEqualByComparingTo("24");
}
@Test void zonesMergeKilometersKeepFixedOccurrencesAndMapDepartureToApproach() throws Exception {
when(catalog.parameter(7,"PZM_MASK_ZONES")).thenReturn("a_km,Zone A;a_fix,Zone Fix;a_eap,Anfahrt;a_min,Minimum");
rates.put("Zone A",rate("2","1",true));rates.put("Zone Fix",rate("5","3",true));rates.put("Anfahrt",rate("1","0.5",true));
var route=route(false,"""
{"zoneDistance":[{"zone":"a_km","distance":5005},{"zone":"a_km","distance":5005},{"zone":"a_fix","distance":0},{"zone":"a_fix","distance":0}],
"extraApproachDistance":[{"zone":"a_eap","distance":2000}],"extraDepartureCalculation":[{"zone":"a_edc","distance":3000}]}
""");
var result=price(with(request(),"pricing.mode","ZONE"),route);
assertThat(result.base()).isEqualByComparingTo("35.02");assertThat(result.lines()).hasSize(5);
assertThat(result.lines().getFirst().quantity()).isEqualByComparingTo("10.01");
}
@Test void longhaulUsesBwvRateDoubleApproachAndOmitsDepartureWhenConfigured() throws Exception {
when(catalog.parameter(7,"PZM_MASK_ZONES")).thenReturn("a_km,Outside;a_eap,Anfahrt");
when(catalog.parameter(0,"BWV_OUTSIDE_PKW")).thenReturn("3");
rates.put("Outside",rate("2","1",true));rates.put("Anfahrt",rate("1","0.5",true));
when(catalog.enabled(7,"PZM_ROUNDTRIPKM")).thenReturn(true);
var route=route(true,"""
{"zoneDistance":[{"zone":"a_km","distance":10000}],"extraApproachDistance":[{"zone":"a_eap","distance":2000}],"extraDepartureCalculation":[{"zone":"a_edc","distance":3000}]}
""");
var result=price(with(request(),"pricing.mode","ZONE"),route);
assertThat(result.base()).isEqualByComparingTo("34");assertThat(result.lines()).hasSize(2);
}
@Test void absentDistanceRatesCannotProduceAnUnintendedFreeOrder() throws Exception {
var route=route(false,"{\"zoneDistance\":[{\"zone\":\"unknown\",\"distance\":5000}]}");
assertThatThrownBy(() -> price(with(request(),"pricing.mode","ZONE"),route)).hasMessageContaining("fehlen gültige Tarife");
assertThatThrownBy(() -> price(with(request(),"pricing.mode","KILOMETER"),route)).hasMessageContaining("km-Preis");
}
}
@@ -102,11 +102,11 @@ class LegacyMappingTest {
assertThat(order.legacyStatusLabel).contains("Storno-Gutschrift");
}
@Test void respectsExplicitWarehouseDirectionBeforeStationPosition() {
var row=new HashMap<String,Object>(Map.of("tr_id",1,"tr_sort",3,"tr_ware_from_to",1));
var row=new HashMap<String,Object>(Map.of("tr_id",1,"tr_sort",3,"tr_ware_from_to",2));
assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.PICKUP);
row.put("tr_ware_from_to",3);
assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.BOTH);
row.put("tr_ware_from_to",2);row.put("tr_sort",1);
row.put("tr_ware_from_to",1);row.put("tr_sort",1);
assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.DELIVERY);
row.put("tr_ware_from_to",0);
assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.PICKUP);
@@ -0,0 +1,141 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryFixtures.*;
import static de.stadtbote.votian.OrderEntryService.fields;
import static de.stadtbote.votian.LegacyOrderPricing.*;
class LegacyOrderPricingTest {
final DatabaseService db=mock(DatabaseService.class);
final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);
final LegacyOrderPricing pricing=new LegacyOrderPricing(db,catalog);
final Map<String,Object> payer=fields("cs_id",31,"hq_id",7,"cs_fixprice_discount",new BigDecimal("10"),"cs_invmode",1);
final LocalDateTime at=LocalDateTime.parse("2026-10-12T10:00:00");
@BeforeEach void setup() {
when(catalog.parameter(anyLong(),anyString())).thenReturn("");
when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(call -> {
String sql=call.getArgument(0);
if(sql.startsWith("SELECT mt_value"))return List.of(fields("mt_value","PKW"));
if(sql.startsWith("SELECT tx_value"))return List.of(fields("tx_value",new BigDecimal("19"),"tx_sign","N"));
if(sql.contains("Globaler<br>Frachtverg."))return List.of(fields("price",new BigDecimal("30"),"discount",0,"courier",30));
return List.of();
});
}
@Test void fixedPriceKeepsMarkupDiscountAndServiceRoundingSeparate() {
var request=with(with(with(request(),"pricing.markup",10),"pricing.courierMarkup",5),"pricing.applyFixedDiscount",true);
var quote=pricing.calculate(USER,request,payer,at);
assertThat(quote.net()).isEqualByComparingTo("109.00"); // 100 * 1.10 * .90 + 10
assertThat(quote.subtotal()).isEqualByComparingTo("100.00");
assertThat(quote.courierNet()).isEqualByComparingTo("73.15"); // 90 * .70 * 1.05 + 10 * .70
assertThat(quote.tax()).isEqualByComparingTo("20.71");assertThat(quote.gross()).isEqualByComparingTo("129.71");
assertThat(quote.components()).hasSize(2);
assertThat(quote.components().getFirst().price()).isEqualByComparingTo("110.00");
assertThat(quote.components().getFirst().discount()).isEqualByComparingTo("10");
assertThat(quote.fingerprint()).hasSize(64);
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void optionalServiceDiscountAndManualCourierPricesFollowPhpOrder() {
when(catalog.enabled(7,"INV_SERVICEPRICE_DISCOUNT")).thenReturn(true);
var request=with(with(with(with(with(request(),"pricing.markup",10),"pricing.courierMarkup",5),"pricing.applyFixedDiscount",true),"pricing.courierPrice",50),"pricing.courierServicePrice",5);
var quote=pricing.calculate(USER,request,payer,at);
assertThat(quote.net()).isEqualByComparingTo("108.00");
assertThat(quote.courierNet()).isEqualByComparingTo("51.75"); // 50 * .90 * 1.05 + 5 * .90
assertThat(quote.courierBase()).isEqualByComparingTo("45.00");
assertThat(quote.courierService()).isEqualByComparingTo("4.50");
}
@Test void priceChangesInvalidateFingerprintAndRoundingIsDecimalHalfUp() {
var first=pricing.calculate(USER,request(),payer,at);
var second=pricing.calculate(USER,with(request(),"pricing.fixedPrice",100.01),payer,at);
assertThat(first.fingerprint()).isNotEqualTo(second.fingerprint());
assertThat(money(new BigDecimal("1.005"))).isEqualByComparingTo("1.01");
assertThat(money(new BigDecimal("-1.005"))).isEqualByComparingTo("-1.01");
}
@Test void negativeCustomerCorrectionsAndPlaceholderMatchPhpTariffRules() {
var specific=new Tariff(new BigDecimal("-2"),BigDecimal.ZERO,new BigDecimal("7"),true);
var common=new Tariff(new BigDecimal("20"),new BigDecimal("5"),new BigDecimal("14"),true);
var result=resolve(specific,common,BigDecimal.ZERO,false);
assertThat(result.price()).isEqualByComparingTo("18");assertThat(result.discount()).isEqualByComparingTo("5");
assertThat(result.courier()).isEqualByComparingTo("14");
assertThat(placeholder(new BigDecimal("0.01"))).isZero();
assertThat(placeholder(new BigDecimal("0.02"))).isEqualByComparingTo("0.02");
}
@Test void pickupCustomerCanSupplyConditionsWithoutChangingTheInvoicePayer() {
var origin=new LinkedHashMap<>(payer);origin.put("cs_jbcond",1);origin.put("cs_fixprice_discount",new BigDecimal("25"));
when(catalog.costCenter(USER,42,false)).thenReturn(origin);
var input=request();var stations=new ArrayList<>(input.stops());var old=stations.getFirst();
stations.set(0,new OrderEntryModels.Station(42L,old.type(),old.name(),old.street(),old.houseNumber(),old.postalCode(),old.city(),old.country(),old.contact(),old.phone(),old.note(),old.reference(),old.photoWorkflow(),old.billingPostalId(),old.attachments()));
var quote=pricing.calculate(USER,with(with(input,"stops",stations),"pricing.applyFixedDiscount",true),payer,at);
assertThat(quote.discount()).isEqualByComparingTo("25");assertThat(input.payerCostCenterId()).isEqualTo(41);
}
@Test void missingRouteTariffsNeverBecomeFreeOrders() {
var input=with(with(request(),"pricing.mode","TARIFF"),"pricing.fixedPrice",null);
assertThatThrownBy(() -> pricing.calculate(USER,input,payer,at)).hasMessageContaining("Kein gültiger Tarif");
payer.put("cs_invmode",0);
assertThatThrownBy(() -> pricing.calculate(USER,input,payer,at)).hasMessageContaining("Festpreis");
}
@Test void basePriceCanBeExemptFromFuelMarkup() {
var tariff=new Tariff(new BigDecimal("10"),new BigDecimal("10"),new BigDecimal("6"),true);
var base=component(0,"Grundpreis","PKW",tariff,BigDecimal.ZERO,BigDecimal.ZERO,new BigDecimal("30"));
var leg=component(1,"PLZ A nach PLZ B","",tariff,new BigDecimal("20"),new BigDecimal("10"),new BigDecimal("30"));
assertThat(base.net()).isEqualByComparingTo("9");assertThat(base.courierNet()).isEqualByComparingTo("5.4");
assertThat(leg.net()).isEqualByComparingTo("10.8");assertThat(leg.courierNet()).isEqualByComparingTo("5.94");
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(ints={1,2,3})
void postalAndAreaTariffsApplyHistoricalRoutePrices(int mode) {
payer.put("cs_invmode",mode);
when(db.queryForList(startsWith("SELECT srvp_id"),eq("20095"),eq("DE"))).thenReturn(List.of(Map.of("srvp_id",101)));
when(db.queryForList(startsWith("SELECT srvp_id"),eq("20097"),eq("DE"))).thenReturn(List.of(Map.of("srvp_id",102)));
when(db.queryForList(startsWith("SELECT m.srvpa_id"),eq(101L),eq(7L))).thenReturn(List.of(Map.of("srvpa_id",201)));
when(db.queryForList(startsWith("SELECT m.srvpa_id"),eq(102L),eq(7L))).thenReturn(List.of(Map.of("srvpa_id",202)));
String table=mode==1?"serviceplzhistory":"serviceplzareahistory";
when(db.queryForList(contains("FROM "+table+" "),any(Object[].class)))
.thenReturn(List.of(fields("price",20,"discount",10,"courier",12)));
var input=with(with(with(with(request(),"pricing.mode","TARIFF"),"pricing.fixedPrice",null),"pricing.markup",10),"pricing.courierMarkup",5);
var quote=pricing.calculate(USER,input,payer,at);
assertThat(quote.net()).isEqualByComparingTo("29.80");
assertThat(quote.courierNet()).isEqualByComparingTo("18.34");
verify(db).queryForList(contains("FROM "+table+" "),eq(7L),eq(31L),eq(2),eq(mode==1?101L:201L),eq(mode==1?102L:202L),eq(at));
}
@Test void longhaulDefersCourierPaymentAndPhotoChangesInvalidateQuote() {
var route=new OrderEntryModels.RouteInfo(new BigDecimal("200"),new BigDecimal("200"),7200,7,"HH",7,true,true,List.of());
var routed=new OrderRoutingService.Result(route,JSON.createObjectNode());
var quote=pricing.calculate(USER,request(),payer,at,routed,List.of(new OrderEntryModels.PhotoChoice(1,2,"Nachweis")));
assertThat(quote.net()).isEqualByComparingTo("110");assertThat(quote.courierNet()).isZero();assertThat(quote.courierBase()).isZero();assertThat(quote.courierService()).isZero();
var changed=pricing.calculate(USER,request(),payer,at,routed,List.of(new OrderEntryModels.PhotoChoice(1,0,"Kein Foto")));
assertThat(changed.fingerprint()).isNotEqualTo(quote.fingerprint());
var retrospective=pricing.calculate(USER,with(request(),"mode","RETROSPECTIVE"),payer,at,routed,List.of());
assertThat(retrospective.courierNet()).isEqualByComparingTo("77");
}
@Test void tollIsExcludedFromFreightButIncludedInTaxAndCollection() {
var quote=pricing.calculate(USER,with(request(),"toll",5),payer,at);
assertThat(quote.net()).isEqualByComparingTo("110");assertThat(quote.tax()).isEqualByComparingTo("21.85");
assertThat(quote.gross()).isEqualByComparingTo("136.85");assertThat(quote.toll()).isEqualByComparingTo("5");
}
@Test void serviceCalculatorPricesQuantityAtHistoricRatesAndRejectsForeignService() {
when(catalog.services(USER,41)).thenReturn(List.of(new OrderEntryModels.ServiceOption(12,14,"Tragehilfe","Stunde")));
when(db.queryForList(contains("FROM servicehistory WHERE"),eq(7L),eq(31L),eq(12L),eq(14L),eq(at)))
.thenReturn(List.of(fields("price",new BigDecimal("30"),"courier",new BigDecimal("20"),"discount",0)));
var input=with(with(with(request(),"pricing.mode","SERVICES"),"pricing.servicePrice",0),"services",List.of(Map.of("serviceId",12,"typeId",14,"quantity",2.5)));
var quote=pricing.calculate(USER,input,payer,at);
assertThat(quote.net()).isEqualByComparingTo("75");assertThat(quote.courierNet()).isEqualByComparingTo("50");
assertThat(quote.components().getFirst().name()).contains("2.5", "Tragehilfe");
assertThatThrownBy(() -> pricing.calculate(USER,with(input,"services",List.of(Map.of("serviceId",999,"typeId",14,"quantity",1))),payer,at)).hasMessageContaining("nicht verfügbar");
}
@Test void quoteIsBoundToEntryHeadquartersEvenWhenPricesMatch() {
var other=new LegacyUser(11,12,8,"entry",USER.rights(),7);
var first=pricing.calculate(USER,request(),payer,at);var second=pricing.calculate(other,request(),payer,at);
assertThat(first.net()).isEqualByComparingTo(second.net());assertThat(first.fingerprint()).isNotEqualTo(second.fingerprint());
}
}
@@ -0,0 +1,52 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.AccessDeniedException;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class MasterAddressSuggestionsTest {
final OrderIntegrationClient client=mock(OrderIntegrationClient.class);
final OrderRoutingService routing=mock(OrderRoutingService.class);
final MasterAddressSuggestions service=new MasterAddressSuggestions(client,routing);
final String token=UUID.randomUUID().toString();final ObjectMapper json=new ObjectMapper();
@Test void scopedRightsAndServerOwnedKey() throws Exception {
when(routing.apiKey()).thenReturn("server-secret");
when(client.googlePlaces(eq("places:autocomplete"),any(),eq("server-secret"),anyString())).thenReturn(json.readTree("{\"suggestions\":[{\"placePrediction\":{\"placeId\":\"test-id\",\"text\":{\"text\":\"Teststraße 1, Berlin\"}}}]}"));
var user=AppointmentServiceTest.user(0);var result=service.suggestions(user,"customers","Teststraße","DE",token);
assertThat(result).containsExactly(new MasterAddressSuggestions.Suggestion("test-id","Teststraße 1, Berlin"));
service.suggestions(user,"centers","Teststraße","DE",token);
assertThatThrownBy(()->service.suggestions(user,"couriers","Teststraße","DE",token)).isInstanceOf(AccessDeniedException.class);
service.suggestions(AppointmentServiceTest.user(1),"couriers","Teststraße","DE",token);
assertThat(json.writeValueAsString(result)).doesNotContain("server-secret");
}
@Test void detailsCopyComponentsIncludingForeignPostalCodes() throws Exception {
when(routing.apiKey()).thenReturn("key");when(client.googlePlaces(startsWith("places/test-id?"),isNull(),eq("key"),eq("addressComponents"))).thenReturn(json.readTree("""
{"addressComponents":[{"types":["route"],"longText":"Baker Street"},{"types":["street_number"],"longText":"221B"},{"types":["postal_code"],"longText":"NW1 6XE"},{"types":["postal_town"],"longText":"London"},{"types":["country"],"shortText":"GB","longText":"United Kingdom"}]}
"""));
assertThat(service.details(AppointmentServiceTest.user(0),"customers","test-id",token)).isEqualTo(new MasterAddressSuggestions.Address("Baker Street","221B","NW1 6XE","London","GB"));
}
@Test void missingValuesAndGermanPostalPrefixesAreCleared() throws Exception {
var address=MasterAddressSuggestions.components(json.readTree("""
[{"types":["route"],"longText":"Teststraße"},{"types":["postal_code"],"longText":"10"},{"types":["postal_code_prefix"],"longText":"10"},{"types":["country"],"shortText":"DE"}]
"""));assertThat(address).isEqualTo(new MasterAddressSuggestions.Address("Teststraße","","","","DE"));
}
@Test void rejectsInvalidRequestsBeforeExternalCalls(){
var user=AppointmentServiceTest.user(0);assertThatThrownBy(()->service.details(user,"customers","../secrets",token)).hasMessageContaining("Ungültiger");
assertThatThrownBy(()->service.suggestions(user,"customers","Test","DE","bad")).hasMessageContaining("Vorgangskennung");
assertThatThrownBy(()->service.suggestions(user,"customers","x".repeat(201),"DE",token)).hasMessageContaining("Adresssuche");
assertThatThrownBy(()->service.suggestions(user,"customers","Test","DE&key=bad",token)).hasMessageContaining("Adresssuche");
assertThatThrownBy(()->service.details(AppointmentServiceTest.user(),"customers","test-id",token)).isInstanceOf(AccessDeniedException.class);
verifyNoInteractions(client);
}
@Test void absentKeyAndServiceFailureRemainVisible(){
var user=AppointmentServiceTest.user(0);when(routing.apiKey()).thenReturn("");
assertThat(service.suggestions(user,"customers","ab","DE",token)).isEmpty();
assertThatThrownBy(()->service.suggestions(user,"customers","Test","DE",token)).hasMessageContaining("manuell");
when(routing.apiKey()).thenReturn("key");when(client.googlePlaces(anyString(),any(),anyString(),anyString())).thenThrow(OrderIntegrationClient.unavailable("Dienst ausgefallen"));
assertThatThrownBy(()->service.suggestions(user,"customers","Test","DE",token)).hasMessageContaining("Dienst ausgefallen");
}
}
@@ -0,0 +1,73 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import org.springframework.security.access.AccessDeniedException;
import java.time.*;
import java.util.*;
import java.util.function.Supplier;
import static de.stadtbote.votian.OrderEntryService.fields;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class MessageAdministrationTest {
final DatabaseService db=mock(DatabaseService.class);
final MessageAdministration service=new MessageAdministration(db,"phoenix_log","phoenix_group");
final LegacyUser user=AppointmentServiceTest.user(4,18),admin=AppointmentServiceTest.user(4,18,10,52);
final List<String> queries=new ArrayList<>(),writes=new ArrayList<>();
final List<Map<String,Object>> inserts=new ArrayList<>();
List<Map<String,Object>> recipients=List.of(fields("id",70L,"label","Testfahrer"));
Map<String,Object> incoming=fields("mf_id",9L,"hq_id",7L,"mf_usr_type",3,"mf_state",0,"usr_id_sender",70L,"mf_body","Anfrage");
Map<String,Object> bulletin=fields("tif_id",8L,"hq_id",7L,"emp_id",0L,"emp_id_creator",99L,"tif_unerasable",0,"tif_text","Bestand");
Map<String,Object> group=fields("msggrp_id",3L,"msggrp_name","Gruppe","msggrp_group","","hq_id",7L);
boolean root,failSecond,foreignMember;int deliveries;
@BeforeEach void setup(){
when(db.readTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.writeTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.queryForObject(anyString(),eq(Long.class),any(Object[].class))).thenReturn(0L);
when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);queries.add(sql);
if(sql.startsWith("SELECT DISTINCT h.hq_id"))return sql.contains(" AND h.hq_id=?")?List.of(fields("id",7L,"label","B")):List.of(fields("id",7L,"label","B"),fields("id",8L,"label","HH"));
if(sql.startsWith("SELECT u.hq_id FROM employee"))return List.of(fields("hq_id",7L));
if(sql.startsWith("SELECT hq_id FROM headquarters"))return root?List.of(fields("hq_id",7L)):List.of();
if(sql.startsWith("SELECT * FROM `phoenix_log`.messageforum"))return List.of(incoming);
if(sql.startsWith("SELECT c.usr_id AS id")||sql.startsWith("SELECT DISTINCT c.usr_id AS id"))return recipients;
if(sql.startsWith("SELECT md_id FROM mandatorheadquarters"))return List.of(fields("md_id",1L));
if(sql.startsWith("SELECT msggrp_id,msggrp_name"))return List.of(group);
return List.of();
});
when(db.lockRows(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);if(sql.startsWith("SELECT hq_id"))return List.of(fields("hq_id",7L));if(sql.contains("tickerforum"))return List.of(bulletin);if(sql.startsWith("SELECT msggrp_id"))return List.of(group);if(sql.startsWith("SELECT cr_id,cr_msggrp"))return List.of(fields("cr_id",10L,"cr_msggrp",",3,4,","hq_id",foreignMember?8L:7L));return List.of();});
when(db.insert(anyString(),anyMap())).thenAnswer(c->{String table=c.getArgument(0);var values=new LinkedHashMap<String,Object>(c.<Map<String,Object>>getArgument(1));inserts.add(values);if(table.contains("messageforum")&&++deliveries==2&&failSecond)throw new IllegalStateException("simulated connection loss");return 100L+inserts.size();});
when(db.update(anyString(),any(Object[].class))).thenAnswer(c->{writes.add(c.getArgument(0));return 1;});
}
MessageAdministration.Dispatch draft(){return new MessageAdministration.Dispatch("Test","Text",1L,List.of(),false,null,null);}
String preview(LegacyUser u,MessageAdministration.Dispatch d){return (String)service.preview(u,d).get("token");}
@Test void permissionsAreIndependentAndCheckedBeforeDatabase(){assertThatThrownBy(()->service.bulletins(AppointmentServiceTest.user(4),false,0)).isInstanceOf(AccessDeniedException.class);assertThatThrownBy(()->service.preview(AppointmentServiceTest.user(18),draft())).isInstanceOf(AccessDeniedException.class);assertThatThrownBy(()->service.saveGroup(user,0,new MessageAdministration.GroupSave(null,"x",List.of()))).isInstanceOf(AccessDeniedException.class);verifyNoInteractions(db);}
@Test void globalBulletinIsExpandedWithinMandatorAndPersonalRecipientWins(){var today=LocalDate.now();service.createBulletin(admin,new MessageAdministration.Bulletin(today,today,0,0,"Alle"));assertThat(inserts).extracting(r->r.get("hq_id")).containsExactly(7L,8L);inserts.clear();service.createBulletin(user,new MessageAdministration.Bulletin(today,today,7,22,"Persönlich"));assertThat(inserts).singleElement().satisfies(r->{assertThat(r).containsEntry("hq_id",0L).containsEntry("emp_id",22L);});}
@Test void bulletinCannotPublishIntoForeignBranchOrPast(){var today=LocalDate.now();assertThatThrownBy(()->service.createBulletin(user,new MessageAdministration.Bulletin(today,today,8,0,"x"))).isInstanceOf(AccessDeniedException.class);assertThatThrownBy(()->service.createBulletin(user,new MessageAdministration.Bulletin(today,today,0,0,"x"))).isInstanceOf(AccessDeniedException.class);assertThatThrownBy(()->service.createBulletin(user,new MessageAdministration.Bulletin(today.minusDays(2),today.minusDays(1),7,0,"x"))).hasMessageContaining("Vergangenheit");assertThat(inserts).isEmpty();}
@Test void oversizedBulletinsAreRejectedRatherThanSilentlyTruncated(){var today=LocalDate.now();assertThatThrownBy(()->service.createBulletin(user,new MessageAdministration.Bulletin(today,today,7,0,"x".repeat(1001)))).hasMessageContaining("1000");assertThat(inserts).isEmpty();}
@Test void bulletinReadsNeverDeleteExpiredMessages(){service.bulletins(user,false,0);assertThat(queries).anyMatch(q->q.contains("t.emp_id_creator=? OR t.emp_id=?")&&q.contains("t.tif_endtime>=?"));assertThat(writes).isEmpty();verify(db,never()).writeTransaction(any());}
@Test void removingBulletinRequiresOwnerOrRootAndCurrentRevision(){assertThatThrownBy(()->service.deleteBulletin(user,8,AdminSupport.revision(bulletin))).isInstanceOf(AccessDeniedException.class);root=true;assertThatThrownBy(()->service.deleteBulletin(user,8,"old")).hasMessageContaining("409");assertThat(writes).isEmpty();}
@Test void historyBindsStateFiltersAndRejectsInvalidPagination(){var q=new MessageAdministration.Query(LocalDate.now(),LocalDate.now(),List.of(0,1),3,"ONLY_HQ",null,2);service.history(user,q);assertThat(queries).anyMatch(s->s.contains("f.hq_id=?")&&s.contains("mf_state IN (?,?)")&&s.contains("mf_type=1")&&s.contains("OFFSET 200"));assertThatThrownBy(()->service.history(user,new MessageAdministration.Query(q.from(),q.to(),List.of(7),3,"ALL",null,0))).hasMessageContaining("400");}
@Test void acknowledgementIsSingleConditionalUpdateWithFirstReader(){service.acknowledge(user,9,AdminSupport.revision(MessageAdministration.messageVersion(incoming)));assertThat(writes).singleElement().asString().contains("usr_id_first_reader=?").contains("mf_state=?").contains("hq_id=?");incoming.put("mf_state",2);assertThatThrownBy(()->service.acknowledge(user,9,AdminSupport.revision(incoming))).hasMessageContaining("bereits gelesen");}
@Test void previewMustSelectExactlyOneRecipientModeAndNoSendOccurs(){assertThatThrownBy(()->service.preview(user,new MessageAdministration.Dispatch("","x",1L,List.of(3L),false,null,null))).hasMessageContaining("genau eine");preview(user,draft());assertThat(inserts).isEmpty();}
@Test void repeatedSendWithSameTokenReturnsCachedResultWithoutDuplicate(){String token=preview(user,draft());var result=service.send(user,token);assertThat(result).containsEntry("queued",1).containsEntry("uncertain",0);assertThat(service.send(user,token)).isSameAs(result);assertThat(deliveries).isEqualTo(1);assertThat(inserts.getFirst()).containsEntry("usr_id_sender",user.userId()).containsEntry("hq_id",user.headquartersId()).containsEntry("usr_id_receiver",70L);}
@Test void changedRecipientSelectionAndDifferentUserCannotSendPreview(){String token=preview(user,draft());recipients=List.of(fields("id",71L,"label","Anderer Fahrer"));assertThatThrownBy(()->service.send(user,token)).hasMessageContaining("409");assertThatThrownBy(()->service.send(new LegacyUser(991,992,7,"other",user.rights()),token)).hasMessageContaining("abgelaufen");assertThat(inserts).isEmpty();}
@Test void myisamPartialFailureRecordsConfirmedUncertainAndUnattemptedRecipients(){recipients=List.of(fields("id",70L,"label","1"),fields("id",71L,"label","2"),fields("id",72L,"label","3"));failSecond=true;String token=preview(admin,new MessageAdministration.Dispatch("","Test",null,List.of(),true,null,null));var result=service.send(admin,token);assertThat(result).containsEntry("queued",1).containsEntry("uncertain",1).containsEntry("unattempted",1);assertThat(result.get("warning").toString()).contains("keine automatische");service.send(admin,token);assertThat(deliveries).isEqualTo(2);}
@Test void replyClaimsOriginalBeforeQueueInsertionToCoordinateWithPhp(){String token=preview(user,new MessageAdministration.Dispatch("AW","Antwort",null,List.of(),false,9L,AdminSupport.revision(incoming)));service.send(user,token);assertThat(inserts.getFirst()).containsEntry("mf_pre_id",9L);assertThat(writes).singleElement().asString().contains("mf_state=3").contains("mf_state<3");}
@Test void groupDeletionRemovesLegacyMembershipsAndRefusesForeignCorruption(){service.deleteGroup(admin,3,AdminSupport.revision(group));verify(db).update("UPDATE courier SET cr_msggrp=? WHERE cr_id=?",",4,",10L);assertThat(writes).anyMatch(s->s.startsWith("DELETE FROM messagegroup")&&s.contains("hq_id=?"));writes.clear();foreignMember=true;assertThatThrownBy(()->service.deleteGroup(admin,3,AdminSupport.revision(group))).hasMessageContaining("fremde");assertThat(writes).isEmpty();}
@Test void allActiveDispatchRequiresGroupAdministrationAndCrossBranchRights(){assertThatThrownBy(()->preview(user,new MessageAdministration.Dispatch("","Text",null,List.of(),true,null,null))).isInstanceOf(AccessDeniedException.class);assertThat(inserts).isEmpty();}
@Test void duplicatedCourierAliasesProduceOnlyOneMessagePerUser(){recipients=List.of(fields("id",70L,"label","Fahrer A"),fields("id",70L,"label","Fahrer B"));String token=preview(user,draft());assertThat(service.send(user,token)).containsEntry("queued",1);assertThat(deliveries).isEqualTo(1);}
@Test void replyClaimLostToAnotherClientNeverQueuesAnotherReply(){String token=preview(user,new MessageAdministration.Dispatch("AW","Antwort",null,List.of(),false,9L,AdminSupport.revision(incoming)));when(db.update(contains("SET mf_state=3"),any(Object[].class))).thenReturn(0);assertThatThrownBy(()->service.send(user,token)).hasMessageContaining("zwischenzeitlich beantwortet");assertThat(inserts).isEmpty();}
@Test void groupEditsQueuePhp203EventsWithoutWritingTheMyisamLogDirectly() {
when(db.queryForList(startsWith("SELECT grp_id AS id"),any(Object[].class))).thenReturn(List.of(fields("id",24L)));
group.put("msggrp_group","23");service.saveGroup(admin,3,new MessageAdministration.GroupSave(AdminSupport.revision(group),"Neu",List.of(24L)));
var events=inserts.stream().filter(r->"ngaudit".equals(r.get("gdc_obj_type"))).map(r->r.get("gdc_content").toString()).toList();
assertThat(events).hasSize(3).anyMatch(v->v.contains("OP=UPDATE")&&v.contains("203")).anyMatch(v->v.contains("OP=ADD_GROUP|GRP_ID=24")).anyMatch(v->v.contains("OP=REMOVE_GROUP|GRP_ID=23"));
assertThat(writes).noneMatch(sql->sql.contains(".log"));
}
@Test void sendToAllIsAuditedOnceForTheSamePreview() {
String token=preview(admin,new MessageAdministration.Dispatch("","Test",null,List.of(),true,null,null));service.send(admin,token);service.send(admin,token);
assertThat(inserts.stream().filter(r->"ngaudit".equals(r.get("gdc_obj_type"))).toList()).singleElement().satisfies(r->assertThat(r.get("gdc_content").toString()).contains("OP=SEND_TO_ALL","203"));
}
}
@@ -0,0 +1,31 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import org.springframework.security.access.AccessDeniedException;
import java.util.*;
import java.util.concurrent.TimeUnit;
import static de.stadtbote.votian.OrderEntryService.fields;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class NewsletterDispatchTest {
final DatabaseService db=mock(DatabaseService.class);final NewsletterService forms=mock(NewsletterService.class);final NewsletterTemplate templates=mock(NewsletterTemplate.class);final AppointmentMailTransport mail=mock(AppointmentMailTransport.class,withSettings().useConstructor("",25,"","",false,false,""));final NewsletterDispatch service=new NewsletterDispatch(db,forms,templates,mail);final LegacyUser user=AppointmentServiceTest.user(0,17,20);
Map<String,Object> rendering=fields("template","<p>Nachricht</p>","revision","r1");List<Map<String,Object>> recipients=List.of(fields("id",9L,"type","cs","label","Testkunde","email","kunde@example.test"));List<String> queries=new ArrayList<>();
@BeforeEach void setup(){when(mail.configured()).thenReturn(true);when(forms.rendering(user,7,12)).thenAnswer(c->rendering);when(templates.render(anyMap())).thenAnswer(c->((Map<String,Object>)c.getArgument(0)).get("template"));when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);queries.add(sql);if(sql.startsWith("SELECT usr_email"))return List.of(fields("usr_email","actor@example.test"));if(sql.startsWith("SELECT DISTINCT cs.cs_id"))return recipients;if(sql.startsWith("SELECT par_value")&&"GLOBAL_UNIQUE_DB_INSTANCE_NO".equals(c.getArgument(2)))return List.of(fields("par_value","1"));return List.of();});}
@AfterEach void close(){service.shutdown();}
NewsletterDispatch.Draft draft(String mode){return new NewsletterDispatch.Draft(7,mode,List.of(7L),"manual@example.test",List.of(0),List.of(),List.of(),"","",false,"Testnachricht");}
String preview(String mode){return (String)service.preview(user,12,draft(mode)).get("token");}
void completed()throws Exception{service.executor.submit(()->{}).get(3,TimeUnit.SECONDS);}
@Test void manualAddressesAreDeduplicatedAndHeaderInjectionRejected(){assertThat(NewsletterDispatch.addresses("A@example.test;a@example.test")).hasSize(1);assertThatThrownBy(()->NewsletterDispatch.addresses("a@example.test\r\nBcc:x@example.test")).hasMessageContaining("Ungültige");}
@Test void previewNeverSendsAndContainsExactRecipients(){var p=service.preview(user,12,draft("manual"));assertThat((List<?>)p.get("recipients")).hasSize(1);verify(mail,never()).sendHtml(any(),any(),any(),any(),any(),any(),any());}
@Test void sameSendTokenStartsExactlyOnceAndStatusSurvivesReopening()throws Exception{String token=preview("manual");service.send(user,token);service.send(user,token);completed();assertThat(service.status(user,token)).containsEntry("sent",1).containsEntry("running",false).containsEntry("uncertain",0);verify(mail,times(1)).sendHtml(any(),any(),eq("manual@example.test"),any(),any(),eq("Testnachricht"),any());assertThat(service.jobs(user)).hasSize(1);}
@Test void recipientAndContentChangesRequireNewPreview(){String token=preview("customers");recipients=List.of(fields("id",10L,"type","cs","label","Neu","email","neu@example.test"));assertThatThrownBy(()->service.send(user,token)).hasMessageContaining("409");rendering=fields("template","neu","revision","r2");assertThatThrownBy(()->service.send(user,token)).hasMessageContaining("geändert");verify(mail,never()).sendHtml(any(),any(),any(),any(),any(),any(),any());}
@Test void anotherSessionUserCannotReuseToken(){String token=preview("manual");var other=new LegacyUser(999,999,7,"other",user.rights());assertThatThrownBy(()->service.send(other,token)).hasMessageContaining("abgelaufen");}
@Test void customerSelectionIncludesConsentTypeAndBranchFilters(){var d=new NewsletterDispatch.Draft(7,"customers",List.of(7L),"",List.of(0,1),List.of(5L),List.of(),"","",false,"x");service.preview(user,12,d);assertThat(queries).anyMatch(s->s.contains("cmp.cmp_newsletter=1")&&s.contains("cmp.cmp_dsgvo=1")&&s.contains("cmp.cmp_type IN (?,?)")&&s.contains("cmp.br_id IN (?)")&&s.contains("ca.adt_id=2")&&s.contains("cs.hq_id IN (?)"));}
@Test void bulkSelectionRequiresAdditionalPhpMailRight(){assertThatThrownBy(()->service.recipients(AppointmentServiceTest.user(0,17),draft("customers"),false)).isInstanceOf(AccessDeniedException.class);}
@Test void personalTemplatesCannotBeSentToUnlinkedManualAddresses(){rendering=fields("template","<a href='https://example.test/?h=<postparser>HASH</postparser>'>Abmelden</a>","revision","r1");assertThatThrownBy(()->preview("manual")).hasMessageContaining("Abmeldelinks");verifyNoInteractions(db);}
@Test void personalizedHashIsResolvedAndLinksAreInertInPreview()throws Exception{rendering=fields("template","<a href='https://example.test/?h=<postparser>HASH</postparser>'>Abmelden</a>","revision","r1");when(db.queryMetaForList(anyString(),any(Object[].class))).thenReturn(List.of(fields("mo_obj_type","cs","mo_obj_id",9L,"mo_hash","personal-test-hash")));var preview=service.preview(user,12,draft("customers"));assertThat(org.jsoup.Jsoup.parse(preview.get("html").toString()).select("a[href]")).isEmpty();service.send(user,preview.get("token").toString());completed();verify(mail).sendHtml(any(),any(),eq("kunde@example.test"),any(),any(),any(),contains("personal-test-hash"));}
@Test void missingHashBlocksSendingInsteadOfProducingInvalidUnsubscribeLink(){rendering=fields("template","<postparser>HASH</postparser>","revision","r1");assertThatThrownBy(()->preview("customers")).hasMessageContaining("Abmeldeschlüssel");verify(mail,never()).sendHtml(any(),any(),any(),any(),any(),any(),any());}
@Test void smtpFailureStopsAtUncertainRecipientAndCannotBeRetried()throws Exception{recipients=List.of(fields("id",9L,"type","cs","label","1","email","one@example.test"),fields("id",10L,"type","cs","label","2","email","two@example.test"),fields("id",11L,"type","cs","label","3","email","three@example.test"));doThrow(new org.springframework.mail.MailSendException("test")).when(mail).sendHtml(any(),any(),eq("two@example.test"),any(),any(),any(),any());String token=preview("customers");service.send(user,token);completed();assertThat(service.status(user,token)).containsEntry("sent",1).containsEntry("uncertain",1).containsEntry("unattempted",1);service.send(user,token);completed();verify(mail,times(2)).sendHtml(any(),any(),any(),any(),any(),any(),any());}
@Test void carbonCopiesMustBeExplicitlyConfirmedForEveryGeneratedMail(){var d=new NewsletterDispatch.Draft(7,"manual",List.of(7L),"one@example.test",List.of(),List.of(),List.of(),"copy@example.test","",false,"x");assertThatThrownBy(()->service.preview(user,12,d)).hasMessageContaining("jeder generierten Mail");}
}
@@ -0,0 +1,10 @@
package de.stadtbote.votian;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.*;import java.io.*;import java.util.*;import java.awt.image.BufferedImage;import javax.imageio.ImageIO;
import static org.assertj.core.api.Assertions.*;
class NewsletterImagesTest {
@TempDir Path dir;
@Test void validatesRasterContentAndNeverUsesCallerFilename()throws Exception{var images=new NewsletterImages(dir.toString());var out=new ByteArrayOutputStream();ImageIO.write(new BufferedImage(2,2,BufferedImage.TYPE_INT_RGB),"png",out);String data="data:image/png;base64,"+Base64.getEncoder().encodeToString(out.toByteArray());String name=images.upload(AppointmentServiceTest.user(0,17),data);assertThat(name).matches("ng_[0-9a-f-]{36}\\.png");assertThat(images.data(name)).isEqualTo(data);assertThat(images.data("../secret.png")).isNull();}
@Test void invalidOversizedOrActiveFormatsAreRejected(){var images=new NewsletterImages(dir.toString());var u=AppointmentServiceTest.user(0,17);for(String v:List.of("data:image/svg+xml;base64,PHN2Zy8+","data:image/png;base64,AAAA","data:image/png;base64,"+"A".repeat(410000)))assertThatThrownBy(()->images.upload(u,v)).isInstanceOf(RuntimeException.class);}
}
@@ -0,0 +1,34 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import org.springframework.security.access.AccessDeniedException;
import java.util.*;
import java.util.function.Supplier;
import static de.stadtbote.votian.OrderEntryService.fields;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class NewsletterServiceTest {
final DatabaseService db=mock(DatabaseService.class);final FormAdministration forms=mock(FormAdministration.class);final MessageAdministration messages=mock(MessageAdministration.class);final NewsletterService service=new NewsletterService(db,forms,messages);final LegacyUser user=AppointmentServiceTest.user(0,17);final NewsletterTemplate renderer=new NewsletterTemplate("https://legacy.example.test",mock(NewsletterImages.class));
List<Map<String,Object>> defs=List.of(fields("id",40L,"keyId",501L,"type","text","label","Text","history",1),fields("id",41L,"keyId",511L,"type","image","label","Bild","history",0));List<Map<String,Object>> raw=List.of(fields("mtfck_id",40L,"mtfv_id",12L,"mtfv_value","Bisher"),fields("mtfck_id",41L,"mtfv_id",12L,"mtfv_value",""));List<String> writes=new ArrayList<>();
@BeforeEach void setup(){when(messages.headquarters(user)).thenReturn(List.of(fields("id",7L,"label","B")));when(forms.fieldHeadquarters(user,10)).thenReturn(7L);when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);if(sql.equals(NewsletterService.DEFINITIONS))return defs;if(sql.startsWith("SELECT mtfck_id,mtfv_id"))return raw;return List.of();});when(db.contactWriteTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());when(db.queryForObject(anyString(),eq(Long.class),any(Object[].class))).thenReturn(13L);when(db.update(anyString(),any(Object[].class))).thenAnswer(c->{writes.add(c.getArgument(0));return 1;});}
NewsletterService.Save draft(String rev,Map<Long,String> values){return new NewsletterService.Save(7,rev,AdminSupport.revision(defs),values);}
@Test void bothLegacyRightsAreRequired(){assertThatThrownBy(()->service.schema(AppointmentServiceTest.user(17),7)).isInstanceOf(AccessDeniedException.class);assertThatThrownBy(()->service.schema(AppointmentServiceTest.user(0),7)).isInstanceOf(AccessDeniedException.class);verifyNoInteractions(db);}
@Test void foreignHeadquartersCannotReadOrWrite(){assertThatThrownBy(()->service.get(user,8,12)).isInstanceOf(AccessDeniedException.class);verifyNoInteractions(db);}
@Test void contentAndFieldHistoryUseExistingEavSchema(){String value=" Neu\n\n";service.save(user,12L,draft(AdminSupport.revision(raw),Map.of(40L,value,41L,"")));verify(db).update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",40L,12L,value);assertThat(writes).anyMatch(s->s.startsWith("INSERT INTO metafieldvaluehistory"));assertThat(writes).contains("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN (?,?)");}
@Test void globalScopeUsesGlobalDefinitionsWithoutChangingTargetUser(){when(forms.fieldHeadquarters(user,10)).thenReturn(0L);assertThat(service.schema(user,7)).containsEntry("headquarters",0L);verify(db).queryForList(NewsletterService.DEFINITIONS,0L);}
@Test void newIdsAreAllocatedInsideTheSerializedMetafieldTransaction(){var result=service.save(user,null,draft(null,Map.of(40L,"Neu")));assertThat(result).containsEntry("id",13L);verify(db).contactWriteTransaction(any());verify(db).queryForObject("SELECT COALESCE(MAX(mtfv_id),0)+1 FROM metafieldvalue",Long.class);}
@Test void staleSchemaAndValuesCannotOverwriteData(){assertThatThrownBy(()->service.save(user,12L,draft("stale",Map.of(40L,"x")))).hasMessageContaining("409");assertThatThrownBy(()->service.save(user,12L,new NewsletterService.Save(7,AdminSupport.revision(raw),"stale",Map.of(40L,"x")))).hasMessageContaining("409");assertThat(writes).isEmpty();}
@Test void missingFieldsPreserveExistingValues(){service.save(user,12L,draft(AdminSupport.revision(raw),Map.of(41L,"bild.jpg,100,200,Titel")));verify(db).update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",40L,12L,"Bisher");}
@Test void malformedImageAndUnknownFieldCannotBeStored(){assertThatThrownBy(()->service.save(user,null,draft(null,Map.of(41L,"../../secret.jpg")))).hasMessageContaining("Bildname");assertThatThrownBy(()->service.save(user,null,draft(null,Map.of(99L,"x")))).hasMessageContaining("Unbekanntes");assertThat(writes).isEmpty();}
@Test void deletionRemovesOnlyThisNewsletterCategoryAndScope(){service.remove(user,7,12,AdminSupport.revision(raw));verify(db).update("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN (?,?)",12L,40L,41L);}
Map<String,Object> rendering(String template,String text,String image){return fields("template",template,"definitions",defs,"data",fields("values",Map.of(40L,text,41L,image)));}
@Test void rendererPreservesTemplateLayoutAndRemovesEmptyConditionalSections(){String html=renderer.render(rendering("<table><tr><td>[-501,3,100-]</td></tr></table><REMOVE_EMPTY_2><p>Illustration</p><CHK_EMPTY_2>[-511-]</CHK_EMPTY_2></REMOVE_EMPTY_2>","Hallo\nWelt",""));assertThat(html).contains("<table>","Hallo", "<br>", "Welt").doesNotContain("Illustration","CHK_EMPTY","[-");}
@Test void rendererRejectsScriptsHandlersUnsafeUrlsAndCss(){String html=renderer.render(rendering("<script>window.top.evil()</script><p onclick=\"evil()\" style=\"color:red;background-image:url(https://evil.test)\">[-501-]</p>","<img src=x onerror=evil()><a href=javascript:evil()>Text</a>",""));assertThat(html).doesNotContain("<script","onclick","onerror","javascript:","evil.test").contains("Text","color:red");}
@Test void imagesRemainOnConfiguredLegacyOriginAndValuesAreEscaped(){String html=renderer.render(rendering("[-511-]","","bild.jpg,100,200,\" onerror=\"evil"));assertThat(html).contains("https://legacy.example.test/documents/images/bild.jpg");var doc=org.jsoup.Jsoup.parse(html);assertThat(doc.select("img").getFirst().hasAttr("onerror")).isFalse();assertThat(doc.select("img").getFirst().attr("width")).isEqualTo("100");}
@Test void renderingNeverRunsSpecialTemplateCode(){assertThatThrownBy(()->renderer.render(rendering("[-B2B:secret-]","x",""))).hasMessageContaining("Spezialplatzhalter");}
@Test void nestedEmptyContainersAreRemovedFromInsideOut(){String html=renderer.render(rendering("<REMOVE_EMPTY_1><p>Outer header</p><CHK_EMPTY_1><REMOVE_EMPTY_2><p>Inner header</p><CHK_EMPTY_2>[-511-]</CHK_EMPTY_2></REMOVE_EMPTY_2></CHK_EMPTY_1></REMOVE_EMPTY_1>","",""));assertThat(html).doesNotContain("Outer header","Inner header","CHK_EMPTY","REMOVE_EMPTY");}
@Test void multilineTemplateSelectorsPreserveFormColumnsWithoutUnsafeCss(){String html=renderer.clean("<style>.mcDiv,\n.mcDivBox\n {width:600px;float:left;background-image:url(https://evil.test)}\n.mcDivLineSpacer15 {clear:left;height:15px}</style><div class=\"mcDiv\">Inhalt</div>");assertThat(html).contains(".mcDiv,\n.mcDivBox\n {width:600px;float:left;}","clear:left;height:15px", "class=\"mcDiv\"").doesNotContain("evil.test","background-image");}
}
@@ -0,0 +1,86 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryFixtures.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryService.fields;
class OrderEntryAccountingTest {
final DatabaseService db=mock(DatabaseService.class);
final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);
final OrderEntryAccounting accounting=new OrderEntryAccounting(db,catalog);
@BeforeEach void setup() {
when(catalog.parameter(anyLong(),anyString())).thenReturn("");
when(catalog.calculatorPayer(USER)).thenReturn(99L);
when(catalog.costCenter(any(),anyLong(),anyBoolean())).thenAnswer(c -> fields("cs_id",31,"csc_name","Stelle "+c.getArgument(1),"cmp_comp","Kunde"));
when(catalog.vehicle(USER,52)).thenReturn(fields("cr_id",61,"crvh_sid","701"));
when(catalog.vehicle(USER,53)).thenReturn(fields("cr_id",62,"crvh_sid","702"));
}
Request splitRequest() {
var stops=new ArrayList<Station>(request().stops());
var node=JSON.valueToTree(stops.getLast());((com.fasterxml.jackson.databind.node.ObjectNode)node).put("costCenterId",42);
try {stops.set(1,JSON.treeToValue(node,Station.class));}catch(Exception e){throw new AssertionError(e);}
return with(with(request(),"stops",stops),"costSplit",true);
}
Request childRequest() {
return with(with(with(with(request(),"mode","RETROSPECTIVE"),"pickupAt","2026-10-10T12:00:00"),"courierVehicleId",52),"courierShares",List.of(new CourierShare(53,new BigDecimal("50"))));
}
@Test void splitsOnlyDistinctInternalCostCentersAndKeepsEveryCent() {
var input=splitRequest();var result=accounting.quote(USER,input,quote(),List.of());
assertThat(result.costAllocations()).extracting(CostAllocation::costCenterId).containsExactly(41L,42L);
assertThat(result.costAllocations()).allSatisfy(a -> {assertThat(a.net()).isEqualByComparingTo("60");assertThat(a.gross()).isEqualByComparingTo("71.40");});
assertThat(result.fingerprint()).isNotEqualTo(quote().fingerprint());
for(String amount:List.of("10.00","0.01","-10.01"))for(int count=2;count<10;count++) {
BigDecimal sum=BigDecimal.ZERO;for(int index=0;index<count;index++)sum=sum.add(OrderEntryAccounting.portion(new BigDecimal(amount),count,index));
assertThat(sum).isEqualByComparingTo(amount);
}
accounting.split(100,result);
verify(db).update("DELETE FROM tourservice WHERE jb_id=?",100L);
verify(db,times(4)).update(startsWith("INSERT INTO tourservice"),any(Object[].class));
when(catalog.costCenter(USER,42,false)).thenReturn(fields("csc_is_extern",1));
assertThatThrownBy(() -> accounting.quote(USER,input,quote(),List.of())).hasMessageContaining("mindestens zwei");
}
@Test void rejectsCashSplitAndDuplicateOrUnauthorizedCouriers() {
assertThatThrownBy(() -> accounting.quote(USER,with(splitRequest(),"payment","CASH"),quote(),List.of())).hasMessageContaining("Rechnungsauftrag");
assertThatThrownBy(() -> accounting.quote(USER,with(childRequest(),"mode","IMMEDIATE"),quote(),List.of())).hasMessageContaining("Nacherfassungen");
var noRight=new LegacyUser(11,12,7,"entry","000000010000000");
assertThatThrownBy(() -> accounting.quote(noRight,childRequest(),quote(),List.of())).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);
assertThatThrownBy(() -> accounting.quote(USER,with(childRequest(),"courierShares",List.of(new CourierShare(53,BigDecimal.TEN),new CourierShare(53,BigDecimal.ONE))),quote(),List.of())).hasMessageContaining("nur einmal");
assertThatThrownBy(() -> accounting.quote(USER,with(childRequest(),"pricing.courierPrice",10),quote(),List.of())).hasMessageContaining("leerlassen");
}
@Test void legacyChildPriceDiscountAndTaxAreIncludedInPreviewAndFingerprint() throws Exception {
when(catalog.enabled(7,"INV_CHILDREN_DISCOUNT")).thenReturn(true);
when(catalog.enabled(0,"MASK_CHILDREN_TAX_HARDCODED")).thenReturn(true);
var node=(com.fasterxml.jackson.databind.node.ObjectNode)JSON.valueToTree(quote());node.put("discount",10);node.put("courierLimit",30);
var result=accounting.quote(USER,childRequest(),JSON.treeToValue(node,Quote.class),List.of());
assertThat(result.courierNet()).isEqualByComparingTo("59.50"); // 50 -10%, +10%, +10 service
assertThat(result.courierAllocations().getFirst().taxSign()).isEqualTo("4");
assertThat(result.courierLimitExceeded()).isTrue();
var changed=accounting.quote(USER,with(childRequest(),"courierShares",List.of(new CourierShare(53,new BigDecimal("40")))),quote(),List.of());
assertThat(changed.fingerprint()).isNotEqualTo(result.fingerprint());
}
@Test void createsBalancedLinkedJobsCopiesToursAndOffsetsTollOnce() {
var input=with(childRequest(),"toll",5);var result=accounting.quote(USER,input,quote(),List.of());
var jobs=new ArrayList<Map<String,Object>>();var ids=new AtomicLong(1000);
when(db.insert(anyString(),anyMap())).thenAnswer(c -> {if(c.getArgument(0).equals("job"))jobs.add(new LinkedHashMap<>(c.getArgument(1)));return ids.incrementAndGet();});
when(db.queryForList(startsWith("SELECT * FROM tour"),eq(100L))).thenReturn(List.of(fields("tr_id",10,"jb_id",100,"tr_sort",1,"tr_status",1)));
var now=LocalDateTime.parse("2026-10-12T10:00:00");
var prepared=new OrderEntryService.Prepared(Map.of(),Map.of(),fields("cr_id",61,"crvh_sid","701"),now,now,0,7,false,31);
var children=accounting.children(USER,input,prepared,100,fields("vht_id",2,"jb_payment",0,"jb_incomplete",0,"jb_tourdata","x","jb_invtext_cr","Tour"),result);
assertThat(children).hasSize(2);assertThat(jobs).hasSize(2);
assertThat(jobs.getFirst()).containsEntry("jb_id_parent",100L).containsEntry("cr_id",62L).containsEntry("jb_status",2);
assertThat(jobs.getLast()).containsEntry("jb_id_parent",children.getFirst()).containsEntry("cr_id",61L);
assertThat(((BigDecimal)jobs.getFirst().get("jb_totalprice")).add((BigDecimal)jobs.getLast().get("jb_totalprice"))).isZero();
verify(db,times(2)).insert(eq("tour"),argThat(row -> !row.containsKey("tr_id")));
verify(db).update(startsWith("UPDATE job SET jb_id_parent=-1"),eq(result.courierNet()),eq(100L),eq(7L));
verify(db).insert(eq("genericdatacontainer"),argThat(row -> row.get("gdc_gen_fieldname").equals("jb_toll") && row.get("gdc_content").equals("-5")));
}
}
@@ -0,0 +1,33 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.Test;
import java.util.*;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryFixtures.*;
class OrderEntryAuditTest {
@Test void recoversAnAlreadyWrittenMyIsamLogWithoutCreatingADuplicate() throws Exception {
var db=mock(DatabaseService.class);var audit=new OrderEntryAudit(db,JSON,"phoenix_log");
when(db.writeTransaction(any())).thenAnswer(call -> ((Supplier<?>)call.getArgument(0)).get());
when(db.lockRows(anyString(),any(Object[].class))).thenReturn(List.of(Map.of("jb_id",101)));
String content=JSON.writeValueAsString(new OrderEntryAudit.Event(11,7,"2026-10-12T10:00:00","Erfassung (VoTiAn NG)|NG_REQUEST=fixture",1));
when(db.queryForList(startsWith("SELECT gdc_id"),any(Object[].class))).thenReturn(List.of(Map.of("gdc_id",9,"gdc_content",content)));
when(db.queryForList(startsWith("SELECT log_id"),any(Object[].class))).thenReturn(List.of()).thenReturn(List.of(Map.of("log_id",1000)));
when(db.update(startsWith("UPDATE genericdatacontainer"),any(Object[].class)))
.thenThrow(new org.springframework.dao.DataAccessResourceFailureException("lost connection after MyISAM insert")).thenReturn(1);
assertThatThrownBy(() -> audit.publish(101)).isInstanceOf(org.springframework.dao.DataAccessException.class);
audit.publish(101);
verify(db,times(1)).update(startsWith("INSERT INTO `phoenix_log`.log"),any(Object[].class));
verify(db,times(2)).update(startsWith("UPDATE genericdatacontainer"),any(Object[].class));
}
@Test void enqueueOnlyWritesTheTransactionalOutbox() {
var db=mock(DatabaseService.class);var audit=new OrderEntryAudit(db,JSON,"phoenix_log");
audit.enqueue(101,USER,java.time.LocalDateTime.parse("2026-10-12T10:00:00"),OrderEntryModels.Mode.RETROSPECTIVE,"fixture");
verify(db).insert(eq("genericdatacontainer"),argThat(fields -> fields.get("gdc_context").equals("PENDING") && fields.get("gdc_content").toString().contains("Nacherfassung")));
verify(db,never()).update(anyString(),any(Object[].class));
}
}
@@ -0,0 +1,32 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.Test;
import java.util.*;
import java.util.function.Supplier;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static org.assertj.core.api.Assertions.*;
import static de.stadtbote.votian.OrderEntryFixtures.USER;
import static de.stadtbote.votian.OrderEntryService.fields;
class OrderEntryCatalogTest {
final DatabaseService db=mock(DatabaseService.class);
final OrderEntryCatalog catalog=new OrderEntryCatalog(db);
@Test void stationAddressesUsePickupDeliveryTypeFourAndKeepCustomerTax() {
when(db.readTransaction(any())).thenAnswer(call -> ((Supplier<?>)call.getArgument(0)).get());
when(db.queryForList(contains("FROM costcenter cc"),any(Object[].class))).thenReturn(List.of(fields("csc_id",41,"cs_id",31,"tx_id",6)));
when(db.queryForList(contains("FROM costcenteraddress"),eq(41L))).thenReturn(List.of(fields("adt_id",4,"cscad_comp","Lager","ad_country","D")));
assertThat(catalog.addresses(USER,41)).hasSize(1);assertThat(catalog.addresses(USER,41).getFirst().country()).isEqualTo("DE");
verify(db,times(2)).queryForList(contains("ca.adt_id=4"),eq(41L));
assertThat(catalog.costCenters(USER,"41").getFirst().taxId()).isEqualTo(6);
}
@org.junit.jupiter.api.Test void technicalCalculatorPayerMayBeHiddenButMustBelongToTheConfiguredBranch() {
var database=org.mockito.Mockito.mock(de.stadtbote.votian.database.DatabaseService.class);
var lookup=new OrderEntryCatalog(database);
org.mockito.Mockito.when(database.queryForList(org.mockito.ArgumentMatchers.contains("FROM parameter"),org.mockito.ArgumentMatchers.eq(7L),org.mockito.ArgumentMatchers.eq("CSC_ID_PAYER_CALCULATOR"))).thenReturn(java.util.List.of(java.util.Map.of("par_value","99")));
org.mockito.Mockito.when(database.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT cc.csc_id FROM costcenter"),org.mockito.ArgumentMatchers.eq(99L),org.mockito.ArgumentMatchers.eq(7L))).thenReturn(java.util.List.of(java.util.Map.of("csc_id",99)));
org.assertj.core.api.Assertions.assertThat(lookup.calculatorPayer(OrderEntryFixtures.USER)).isEqualTo(99);
org.mockito.Mockito.when(database.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT cc.csc_id FROM costcenter"),org.mockito.ArgumentMatchers.eq(99L),org.mockito.ArgumentMatchers.eq(7L))).thenReturn(java.util.List.of());
org.assertj.core.api.Assertions.assertThatThrownBy(() -> lookup.calculatorPayer(OrderEntryFixtures.USER)).hasMessageContaining("Niederlassung");
}
}
@@ -0,0 +1,12 @@
package de.stadtbote.votian;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.*;
import static de.stadtbote.votian.OrderEntryModels.*;
class OrderEntryExtrasTest {
@ParameterizedTest @CsvSource({"1,1,11.90","100000,1,83.30","100000,2,208.25","100000,3,85.00","100000,4,200.00","100000,5,83.30","12345,1,11.90","12345,2,25.71"})
void premiumUsesLegacyCeilingAndMinimum(String value,int rate,String expected) {
assertThat(OrderEntryExtras.insurancePremium(new Insurance(new BigDecimal(value),rate,false))).isEqualByComparingTo(expected);
}
}
@@ -0,0 +1,38 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import static de.stadtbote.votian.OrderEntryModels.*;
final class OrderEntryFixtures {
static final ObjectMapper JSON=new ObjectMapper().findAndRegisterModules();
static final LegacyUser USER=new LegacyUser(11,12,7,"entry-test","1".repeat(40));
static Request request() {
try {return JSON.readValue("""
{"mode":"IMMEDIATE","orderer":"Testbesteller","payerCostCenterId":41,"payment":"INVOICE","vehicleTypeId":2,
"stops":[
{"type":"PICKUP","name":"Testlager","street":"Teststraße","houseNumber":"1","postalCode":"20095","city":"Hamburg","country":"DE"},
{"type":"DELIVERY","name":"Testempfang","street":"Testweg","houseNumber":"2","postalCode":"20097","city":"Hamburg","country":"DE"}],
"pricing":{"mode":"FIXED","fixedPrice":100,"servicePrice":10,"taxId":4},
"quoteFingerprint":"checked","requestId":"11111111-2222-4333-8444-555555555555"}
""",Request.class);}catch(Exception e){throw new AssertionError(e);}
}
static Request with(Request request,String path,Object value) {
try {
ObjectNode tree=JSON.valueToTree(request);String[] parts=path.split("\\.");ObjectNode target=tree;
for(int i=0;i<parts.length-1;i++)target=(ObjectNode)target.get(parts[i]);
target.set(parts[parts.length-1],JSON.valueToTree(value));
return JSON.treeToValue(tree,Request.class);
}catch(Exception e){throw new AssertionError(e);}
}
static Quote quote() {
try {return JSON.readValue("""
{"net":120,"tax":22.8,"gross":142.8,"subtotal":110,"courierNet":84,"courierSubtotal":77,
"markup":10,"courierMarkup":10,"discount":0,"taxRate":19,"taxSign":"N","courierBase":70,"courierService":7,
"courierDeduction":30,"invoiceMode":1,"batch":false,"components":[
{"sequence":0,"name":"Fixpreis","type":"","price":110,"discount":0,"net":110,"subtotal":100,"courierNet":77,"courierSubtotal":70},
{"sequence":0,"name":"Servicepreis","type":"","price":10,"discount":0,"net":10,"subtotal":10,"courierNet":7,"courierSubtotal":7}],
"fingerprint":"checked"}
""",Quote.class);}catch(Exception e){throw new AssertionError(e);}
}
}
@@ -0,0 +1,46 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import org.springframework.security.access.AccessDeniedException;
import java.util.*;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryFixtures.*;
import static de.stadtbote.votian.OrderEntryService.fields;
class OrderEntryHeadquartersTest {
final DatabaseService db=mock(DatabaseService.class);
final OrderEntryHeadquarters service=new OrderEntryHeadquarters(db);
String bits="1".repeat(48),allowed="8";long admin=99;
List<Map<String,Object>> rights=new ArrayList<>(List.of(fields("hq_id",8,"rgt_id",8)));
@BeforeEach void setup() {
when(db.readTransaction(any())).thenAnswer(c -> ((Supplier<?>)c.getArgument(0)).get());
when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c -> {
String sql=c.getArgument(0);
if(sql.contains("e.emp_rights"))return List.of(fields("emp_rights",bits,"emp_headquarters",allowed,"hq_admin",admin));
if(sql.contains("mandatorheadquarters"))return List.of(fields("hq_id",7,"hq_mnemonic","Own"),fields("hq_id",8,"hq_mnemonic","Other"));
if(sql.contains("employeerights"))return rights;
return List.of();
});
}
@Test void allowsOnlySameMandatorWithBothEmployeeAndEntryGrants() {
assertThat(service.available(USER)).extracting(OrderEntryModels.Option::id).containsExactly(7L,8L);
var scoped=service.select(USER,8);assertThat(scoped.headquartersId()).isEqualTo(8);assertThat(scoped.originalHeadquartersId()).isEqualTo(7);
assertThat(scoped.hasRight(14)).isFalse();assertThat(USER.headquartersId()).isEqualTo(7);
assertThatThrownBy(() -> service.select(USER,9)).isInstanceOf(AccessDeniedException.class);
allowed="";assertThatThrownBy(() -> service.select(USER,8)).isInstanceOf(AccessDeniedException.class);
allowed="8";rights.clear();assertThatThrownBy(() -> service.select(USER,8)).isInstanceOf(AccessDeniedException.class);
}
@Test void respectsCallCenterMaskAndIndependentWarehouseGrant() {
rights.add(fields("hq_id",8,"rgt_id",15));assertThat(service.select(USER,8).hasRight(14)).isTrue();
bits=bits.substring(0,10)+"0"+bits.substring(11);
var scoped=service.select(USER,8);assertThat(scoped.hasRight(7)).isTrue();assertThat(scoped.hasRight(14)).isFalse();assertThat(scoped.hasRight(38)).isFalse();
}
@Test void adminCannotEscapeMandatorAndRevokedEntryRightIsRejected() {
admin=12;allowed="";rights.clear();assertThat(service.select(USER,8).headquartersId()).isEqualTo(8);
assertThatThrownBy(() -> service.select(USER,9)).isInstanceOf(AccessDeniedException.class);
bits="0".repeat(48);assertThat(service.available(USER)).isEmpty();
assertThatThrownBy(() -> service.select(USER,7)).isInstanceOf(AccessDeniedException.class);
}
}
@@ -0,0 +1,111 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryFixtures.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryService.fields;
class OrderEntryInventoryTest {
final DatabaseService db=mock(DatabaseService.class);
final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);
final OrderEntryInventory inventory=new OrderEntryInventory(db,catalog);
Map<String,Object> article=fields("at_id",3,"at_name","Testartikel","at_eid","A3","at_serialno",0,"at_stk_itemquantity",10,"at_stk_areaquantity",1,"at_totalweight",2,"at_packingpieces",1,"at_bundlequantity",1);
Map<String,Object> source=fields("stk_id",8,"stk_pre_id",0,"stk_name","Quelle","stk_visible",1,"stk_readonly",0,"cs_id",31,"stk_maxquantity",100,"stk_planning",1);
Map<String,Object> target=fields("stk_id",9,"stk_pre_id",0,"stk_name","Ziel","stk_visible",1,"stk_readonly",0,"cs_id",31,"stk_maxquantity",10,"stk_planning",1);
long available=10,used=0;List<Map<String,Object>> serials=List.of();String restriction="";
@BeforeEach void setup() {
when(db.lockRows(anyString(),any(Object[].class))).thenAnswer(c -> db.queryForList(c.getArgument(0),(Object[])java.util.Arrays.copyOfRange(c.getArguments(),1,c.getArguments().length)));
when(catalog.costCenter(USER,41,true)).thenReturn(fields("cs_id",31));
when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c -> {
String sql=c.getArgument(0);
if(sql.startsWith("SELECT * FROM article"))return List.of(article);
if(sql.startsWith("SELECT * FROM stock"))return List.of(((Number)c.getArgument(1)).longValue()==8?source:target);
if(sql.contains("MASK_STK_SUBSTOCK_ACCESS"))return restriction.isEmpty()?List.of():List.of(fields("par_value",restriction));
if(sql.startsWith("SELECT stkat_itemquantity"))return List.of(fields("stkat_itemquantity",((Number)c.getArgument(1)).longValue()==8?available:0));
if(sql.contains(" AS occupied"))return List.of(fields("occupied",used));
if(sql.contains(" AS reserved"))return List.of(fields("reserved",0));
if(sql.startsWith("SELECT stk_id FROM articleitem"))return serials;
if(sql.contains("information_schema.TABLES"))return List.of(fields("ENGINE","InnoDB"));
return List.of();
});
}
Request input(int quantity,StockBooking booking) {return with(request(),"articles",List.of(new ArticleItem(3,1,quantity,"","",booking)));}
@Test void previewsArticlesWithoutBookingOrRepricingTransport() {
var result=inventory.quote(USER,input(3,null));
assertThat(result).hasSize(1);assertThat(result.getFirst().weight()).isEqualByComparingTo("6");
assertThat(result.getFirst().booked()).isFalse();verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void validatesAccumulatedQuantitiesAcrossLinesAndCopies() {
var input=input(6,new StockBooking(8,9));
assertThat(inventory.quote(USER,input).getFirst().destination()).isEqualTo("Ziel");
assertThatThrownBy(() -> inventory.quote(USER,with(input,"copies",2))).hasMessageContaining("Nicht genügend Bestand");
assertThatThrownBy(() -> inventory.quote(USER,with(input,"articles",List.of(input.articles().getFirst(),input.articles().getFirst())))).hasMessageContaining("Nicht genügend Bestand");
assertThatThrownBy(() -> inventory.quote(USER,input(11,new StockBooking(8,0)))).hasMessageContaining("Nicht genügend Bestand");
}
@Test void enforcesStockCapacityAndArticleAreaConversion() {
used=10;
assertThatThrownBy(() -> inventory.quote(USER,input(1,new StockBooking(8,9)))).hasMessageContaining("Lagerkapazität");
assertThat(OrderEntryInventory.areas(article,11)).isEqualTo(2);
target.put("stk_planning",0);assertThat(inventory.quote(USER,input(1,new StockBooking(8,9)))).hasSize(1);
}
@Test void rechecksCurrentStockAfterLockingInsteadOfUsingAnOlderSnapshot() {
var input=input(1,new StockBooking(8,9));
assertThat(inventory.quote(USER,input)).hasSize(1);
doReturn(List.of(fields("stkat_itemquantity",0))).when(db)
.lockRows(startsWith("SELECT stkat_itemquantity"),eq(8L),eq(3L));
assertThatThrownBy(() -> inventory.quote(USER,input,true)).hasMessageContaining("Nicht genügend Bestand");
// A failed write preview must not leak locking reads into later read-only requests.
assertThat(inventory.quote(USER,input)).hasSize(1);
}
@Test void requiresRightsCustomerScopeWritableStockAndSubstockAccess() {
var noStock=new LegacyUser(11,12,7,"entry","000000010000000");when(catalog.costCenter(noStock,41,true)).thenReturn(fields("cs_id",31));
assertThatThrownBy(() -> inventory.quote(noStock,input(1,new StockBooking(8,9)))).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);
target.put("cs_id",32);assertThatThrownBy(() -> inventory.quote(USER,input(1,new StockBooking(8,9)))).hasMessageContaining("Kundenzuordnung");
target.put("cs_id",31);target.put("stk_readonly",1);assertThatThrownBy(() -> inventory.quote(USER,input(1,new StockBooking(8,9)))).hasMessageContaining("Schreibrechte");
target.put("stk_readonly",0);restriction="9,99";assertThatThrownBy(() -> inventory.quote(USER,input(1,new StockBooking(8,9)))).hasMessageContaining("Schreibrechte");
}
@Test void checksSerialsAndRejectsRepeatedBookingsInTemplatesOrSeries() {
article.put("at_serialno",1);
assertThatThrownBy(() -> inventory.quote(USER,input(1,null))).hasMessageContaining("Seriennummer");
var input=with(request(),"articles",List.of(new ArticleItem(3,1,1,"SN-1","",new StockBooking(8,9))));
assertThatThrownBy(() -> inventory.quote(USER,input)).hasMessageContaining("Quelllager");
serials=List.of(fields("stk_id",8));assertThat(inventory.quote(USER,input)).hasSize(1);
assertThatThrownBy(() -> inventory.quote(USER,with(input,"copies",2))).hasMessageContaining("Quelllager");
assertThatThrownBy(() -> inventory.quote(USER,with(input,"tourName","Vorlage"))).hasMessageContaining("Tourvorlagen");
assertThatThrownBy(() -> inventory.quote(USER,with(input,"mode","RECURRING"))).hasMessageContaining("Daueraufträgen");
}
@Test void persistsLegacyJournalAndPreservesSerialMetadataOnTransfer() {
var input=with(request(),"articles",List.of(new ArticleItem(3,1,1,"SN-1","Vorsichtig",new StockBooking(8,9))));
when(db.update(anyString(),any(Object[].class))).thenReturn(1);
inventory.lock(USER,input);
inventory.persist(USER,input,100,List.of(101L,102L),java.time.LocalDateTime.parse("2026-10-12T10:00:00"));
verify(db).lockRows(contains("FROM stock WHERE"),eq(8L),eq(7L));
verify(db).lockRows(contains("FROM stock WHERE"),eq(9L),eq(7L));
verify(db).insert(eq("tourarticle"),argThat(row -> row.get("tr_id").equals(101L) && row.get("jb_id").equals(100L) && row.get("trat_quantity").equals(1)));
verify(db).insert(eq("stockmove"),argThat(row -> row.get("stk_id_from").equals(8L) && row.get("stk_id_to").equals(9L) && row.get("jb_id").equals(100L)));
verify(db).update(startsWith("UPDATE articleitem SET stk_id"),eq(9L),eq(3L),eq(8L),eq("SN-1"));
verify(db,never()).insert(eq("articleitem"),anyMap());
}
@Test void refusesNonTransactionalTablesBeforeAnyWrite() {
when(db.queryForList(contains("information_schema.TABLES"),eq("stockmove"))).thenReturn(List.of(fields("ENGINE","MyISAM")));
assertThatThrownBy(() -> inventory.lock(USER,input(1,new StockBooking(8,9)))).hasMessageContaining("stockmove");
verify(db,never()).insert(anyString(),anyMap());verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void freeArticlesPersistSnapshotWithoutCatalogOrStockMovement() {
var free=new FreeArticle("Freie Kiste",new java.math.BigDecimal("12.750"),2,new java.math.BigDecimal("23.50"));
var input=with(request(),"articles",List.of(new ArticleItem(0,1,3,"","Fragil",null,free)));
assertThat(inventory.quote(USER,input).getFirst().weight()).isEqualByComparingTo("12.750");
inventory.lock(USER,input);inventory.persist(USER,input,100,List.of(101L,102L),java.time.LocalDateTime.now());
verify(db).insert(eq("tourarticle"),argThat(row -> ((Number)row.get("at_id")).longValue()==0 && row.get("trat_name").equals("Freie Kiste") && ((java.math.BigDecimal)row.get("trat_price")).compareTo(free.price())==0 && ((java.math.BigDecimal)row.get("trat_weight")).compareTo(free.totalWeight())==0));
verify(db,never()).queryForList(startsWith("SELECT * FROM article"),any(Object[].class));
verify(db,never()).insert(eq("stockmove"),anyMap());
assertThatThrownBy(() -> inventory.quote(USER,with(input,"articles",List.of(new ArticleItem(0,1,3,"","",new StockBooking(8,9),free))))).hasMessageContaining("Lager");
assertThatThrownBy(() -> inventory.quote(USER,with(input,"articles",List.of(new ArticleItem(3,1,3,"","",null,free))))).hasMessageContaining("Artikel");
}
}
@@ -0,0 +1,28 @@
package de.stadtbote.votian;
import org.junit.jupiter.api.*;
import org.springframework.mock.web.*;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import static de.stadtbote.votian.OrderEntryFixtures.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class OrderEntryScopeFilterTest {
final OrderEntryHeadquarters headquarters=mock(OrderEntryHeadquarters.class);
final OrderEntryScopeFilter filter=new OrderEntryScopeFilter(headquarters,JSON);
@BeforeEach void login(){SecurityContextHolder.getContext().setAuthentication(UsernamePasswordAuthenticationToken.authenticated(USER,null,java.util.List.of()));}
@AfterEach void clear(){SecurityContextHolder.clearContext();}
MockHttpServletRequest request(String path,String method,String scope){var r=new MockHttpServletRequest(method,path);r.setServletPath(path);r.addHeader("X-Order-Headquarters",scope);return r;}
@Test void restoresOriginalPrincipalAfterSuccessAndException() throws Exception {
var selected=new LegacyUser(11,12,8,"entry",USER.rights(),7);when(headquarters.select(USER,8)).thenReturn(selected);
filter.doFilter(request("/api/orders/entry/options","GET","8"),new MockHttpServletResponse(),(r,s) -> assertThat(SecurityContextHolder.getContext().getAuthentication().getPrincipal()).isEqualTo(selected));
assertThat(SecurityContextHolder.getContext().getAuthentication().getPrincipal()).isEqualTo(USER);
assertThatThrownBy(() -> filter.doFilter(request("/api/orders","POST","8"),new MockHttpServletResponse(),(r,s) -> {throw new IllegalStateException("test");})).isInstanceOf(IllegalStateException.class);
assertThat(SecurityContextHolder.getContext().getAuthentication().getPrincipal()).isEqualTo(USER);
}
@Test void ignoresHeaderOnOtherApisAndRejectsMalformedScope() throws Exception {
filter.doFilter(request("/api/orders","GET","8"),new MockHttpServletResponse(),(r,s) -> assertThat(SecurityContextHolder.getContext().getAuthentication().getPrincipal()).isEqualTo(USER));
filter.doFilter(request("/api/orders/entry/headquarters","GET","8"),new MockHttpServletResponse(),(r,s) -> {});
verifyNoInteractions(headquarters);
assertThatThrownBy(() -> filter.doFilter(request("/api/orders/quote","POST","-1"),new MockHttpServletResponse(),(r,s) -> fail("must not reach"))).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);
}
}
@@ -0,0 +1,22 @@
package de.stadtbote.votian;
import org.junit.jupiter.api.Test;
import java.time.*;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static de.stadtbote.votian.OrderEntryModels.*;
class OrderEntrySeriesTest {
@Test void monthlyClampsDayAndEndOfMonthMovesBackOnlyOverConfiguredHolidays() {
var feb=LocalDate.of(2027,2,28);
assertThat(OrderEntrySeries.next(LocalDate.of(2027,1,31),new Recurrence(6,"1111100",null),Set.of(),feb)).isEqualTo(feb);
assertThat(OrderEntrySeries.next(LocalDate.of(2027,1,31),new Recurrence(7,"1111100",null),Set.of(feb),feb)).isEqualTo(feb.minusDays(1));
}
@Test void holidayRhythmCombinesHolidaysWithSelectedRegularWeekdays() {
var start=LocalDate.of(2026,10,12);var end=start.plusDays(10);var holiday=start.plusDays(3);
assertThat(OrderEntrySeries.next(start,new Recurrence(9,"0000000",end),Set.of(holiday),end)).isEqualTo(holiday);
assertThat(OrderEntrySeries.next(start,new Recurrence(9,"0100000",end),Set.of(holiday),end)).isEqualTo(start.plusDays(1));
}
@Test void weeklyMovesForwardOverConfiguredHoliday() {
var start=LocalDate.of(2026,10,12);var holiday=start.plusDays(7);
assertThat(OrderEntrySeries.next(start,new Recurrence(4,"0000000",null),Set.of(holiday),start.plusMonths(1))).isEqualTo(holiday.plusDays(1));
}
}
@@ -0,0 +1,348 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import jakarta.validation.Validation;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryFixtures.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryService.fields;
class OrderEntryServiceTest {
final DatabaseService db=mock(DatabaseService.class);
final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);
final LegacyOrderPricing pricing=mock(LegacyOrderPricing.class);
final OrderEntryAudit audit=mock(OrderEntryAudit.class);
final OrderRoutingService routing=mock(OrderRoutingService.class);
final OrderPhotoWorkflows photos=mock(OrderPhotoWorkflows.class);
final List<Map<String,Object>> jobs=new ArrayList<>(),stops=new ArrayList<>();
List<Map<String,Object>> previous=List.of();
final OrderEntryService service;
OrderEntryServiceTest() {
var factory=Validation.buildDefaultValidatorFactory();
service=new OrderEntryService(db,catalog,pricing,factory.getValidator(),audit,routing,photos,"phoenix_log",
Clock.fixed(Instant.parse("2026-10-12T08:00:00Z"),ZoneId.of("Europe/Berlin")));
factory.close();
}
@BeforeEach void setup() {
when(db.readTransaction(any())).thenAnswer(call -> ((Supplier<?>)call.getArgument(0)).get());
when(db.writeTransaction(any())).thenAnswer(call -> ((Supplier<?>)call.getArgument(0)).get());
when(db.lockRows(anyString(),any(Object[].class))).thenReturn(List.of(Map.of("usr_id",11)));
when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(call -> {
String sql=call.getArgument(0);
if(sql.startsWith("SELECT cs.cs_payment_term"))return List.of(Map.of("cs_payment_term",10,"mt_value","10 Tage netto"));
if(sql.contains("information_schema.TABLES"))return java.util.Collections.nCopies(9,Map.of("ENGINE","InnoDB"));
if(sql.contains("SELECT sp_context"))return previous;
if(sql.startsWith("SELECT jb_status,jb_ordertime"))return List.of(Map.of("jb_status",9));
return List.of();
});
var ids=new AtomicLong(100);
when(db.insert(anyString(),anyMap())).thenAnswer(call -> {
String table=call.getArgument(0);Map<String,Object> fields=call.getArgument(1);
if(table.equals("job"))jobs.add(new LinkedHashMap<>(fields));
if(table.equals("tour"))stops.add(new LinkedHashMap<>(fields));
return ids.incrementAndGet();
});
when(catalog.costCenter(any(),anyLong(),anyBoolean())).thenReturn(fields("cs_id",31,"hq_id",7,"cmp_postage",0));
when(catalog.headquarters(USER)).thenReturn(fields("hq_workmode",1,"hq_invmode",1));
when(catalog.parameter(anyLong(),anyString())).thenReturn("");
when(catalog.vehicle(USER,52)).thenReturn(fields("cr_id",61,"crvh_sid","701","vht_id",2));
when(pricing.calculate(any(),any(),anyMap(),any(),nullable(OrderRoutingService.Result.class),anyList())).thenReturn(quote());
}
@Test void writesOneCompleteHqOrderAndAuditsOnlyAfterTheTransaction() {
var created=service.create(USER,request());
assertThat(created.id()).isEqualTo(101);assertThat(created.legacyStatus()).isEqualTo(9);
assertThat(jobs.getFirst()).containsEntry("csc_id",0).containsEntry("csc_id_payer",41L).containsEntry("emp_id",11L)
.containsEntry("hq_id",7L).containsEntry("jb_status",9).containsEntry("jb_totalprice",quote().net());
assertThat(stops).hasSize(2);assertThat(stops.getFirst()).containsEntry("tr_ware_from_to",2).containsEntry("tr_sort",1);
assertThat(stops.getLast()).containsEntry("tr_ware_from_to",1).containsEntry("tr_status",0);
var order=inOrder(db,audit);
order.verify(db).writeTransaction(any());
order.verify(audit).enqueue(eq(101L),eq(USER),any(),eq(Mode.IMMEDIATE),eq(request().requestId()));
order.verify(audit).tryPublish(101);
verify(db,never()).update(contains(".log "),any(Object[].class));
}
@Test void cashPaymentPreservesTheActualPayerAndCollectionStation() {
when(catalog.parameter(7,"CSC_ID_PAYER_CASH")).thenReturn("99");
when(catalog.enabled(7,"MASK_CASH_PAYER_SELECT")).thenReturn(true);
service.create(USER,with(with(request(),"payment","CASH"),"cashStop",2));
assertThat(jobs.getFirst()).containsEntry("csc_id_payer",99L).containsEntry("csc_id_payer_cash",41L).containsEntry("jb_payment",2);
assertThat(stops.getLast().get("tr_remark").toString()).contains("142.8","kassieren");
verify(db).update(contains("INSERT INTO tourservice"),eq(101L),eq(99L),eq(0),eq("Fixpreis"),eq(""),any(),any());
}
@Test void requiredCustomerFiltersAndMediationArePassedToTheLegacyDispatcher() {
when(catalog.parameter(7,"MODE_INTERMEDIATION")).thenReturn("2");
when(catalog.parameter(7,"MASK_MANDATORY_FILTERS")).thenReturn("PFLICHT");
when(catalog.costCenter(USER,41,true)).thenReturn(fields("cs_id",31,"hq_id",7,"cs_filter",",TEST,LZ,"));
when(catalog.enabled(0,"CUSTOMER_MASK_JB_INCOMPLETE_31")).thenReturn(true);
when(db.queryForList(contains("a.srvpa_name"),eq("20095"),eq(7L)))
.thenReturn(List.of(Map.of("srvpa_id",123,"srvpa_name","Testgebiet")));
when(db.queryForList(contains("FROM courierfilter"))).thenReturn(List.of(
Map.of("crf_short","TEST","crf_status",1,"crf_longtext","Anforderung"),Map.of("crf_short","LZ","crf_status",2,"crf_longtext","")));
service.create(USER,request());
assertThat(jobs.getFirst()).containsEntry("jb_mediationarea_id",123L).containsEntry("jb_mediationarea_name","Testgebiet")
.containsEntry("jb_cr_filter","TEST,PFLICHT").containsEntry("jb_cr_filter_opt","LZ").containsEntry("jb_incomplete",1)
.containsEntry("jb_tourdata","20095;20097|Hamburg;Hamburg|DE;DE");
assertThat(stops.getFirst().get("tr_remark").toString()).contains("Anforderung","LIEFERSCHEIN").doesNotContain("Frachtverguetung");
}
@Test void retrospectiveCompletesEveryStationAndUsesUserIdInsteadOfEmployeeId() {
var input=with(with(with(request(),"mode","RETROSPECTIVE"),"pickupAt","2026-10-10T12:30:00"),"courierVehicleId",52);
service.create(USER,input);
assertThat(jobs.getFirst()).containsEntry("jb_status",2).containsEntry("cr_id",61L).containsEntry("cr_sid","701")
.containsEntry("jb_reserv",0).containsEntry("emp_id",11L).containsEntry("jb_taketime",LocalDateTime.parse("2026-10-10T12:30:00"));
assertThat(stops).allSatisfy(s -> assertThat(s).containsEntry("tr_status",1).containsEntry("tr_finishtime",LocalDateTime.parse("2026-10-10T12:30:00")));
verify(db,never()).update(startsWith("UPDATE courier"),any(Object[].class));
}
@Test void recurringStoresTheExactLegacyRhythmWeekdayMaskAndInclusiveEnd() {
var input=with(with(with(request(),"mode","RECURRING"),"pickupAt","2026-10-14T08:00:00"),"recurrence",Map.of("rhythm",9,"weekdays","0000000","endDate","2027-01-31"));
service.create(USER,input);
assertThat(jobs.getFirst()).containsEntry("jb_permanent",9).containsEntry("jb_permanent2","0000000")
.containsEntry("jb_reserv",1).containsEntry("jp_permenddat",LocalDateTime.parse("2027-01-31T23:59:59"));
}
@Test void duplicateRequestReturnsTheExistingOrderAndDifferentPayloadConflicts() {
previous=List.of(Map.of("sp_context","7|"+OrderEntryService.requestHash(request())+"|123"));
var result=service.create(USER,request());assertThat(result.id()).isEqualTo(123);assertThat(result.replayed()).isTrue();
verify(db,never()).insert(anyString(),anyMap());
assertThatThrownBy(() -> service.create(USER,with(request(),"reference","changed"))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
}
@Test void staleQuoteAndForeignPayerCannotWriteAnything() {
assertThatThrownBy(() -> service.create(USER,with(request(),"quoteFingerprint","stale"))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
when(catalog.costCenter(USER,41,true)).thenThrow(OrderEntryCatalog.invalid("Fremde Kostenstelle"));
assertThatThrownBy(() -> service.create(USER,request())).hasMessageContaining("Fremde Kostenstelle");
verify(db,never()).insert(anyString(),anyMap());verifyNoInteractions(audit);
}
@Test void invalidDatesRecurrencesAndMissingRightsAreRejectedBeforeDatabaseAccess() {
var none=new LegacyUser(11,12,7,"no-right","0".repeat(40));
assertThatThrownBy(() -> service.create(none,request())).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> service.create(USER,with(request(),"mode","RESERVATION"))).hasMessageContaining("Datum");
var series=with(with(with(request(),"mode","RECURRING"),"pickupAt","2026-10-14T08:00:00"),"recurrence",Map.of("rhythm",1,"weekdays","0000000"));
assertThatThrownBy(() -> service.create(USER,series)).hasMessageContaining("Wochentag");
var ambiguous=with(with(request(),"mode","RESERVATION"),"pickupAt","2026-10-25T02:30:00");
assertThatThrownBy(() -> service.create(USER,ambiguous)).hasMessageContaining("Zeitumstellung");
verifyNoInteractions(db);
}
@Test void courierPriceRequiresSeparateLegacyRight38() {
var user=new LegacyUser(11,12,7,"entry-only","000000010000");
assertThatThrownBy(() -> service.create(user,with(request(),"pricing.courierPrice",10))).isInstanceOf(AccessDeniedException.class);
verifyNoInteractions(db);
}
@Test void stationFailureCannotPublishAnAuditOrReturnSuccess() {
when(db.insert(eq("tour"),anyMap())).thenThrow(new org.springframework.dao.DataAccessResourceFailureException("fixture"));
assertThatThrownBy(() -> service.create(USER,request())).isInstanceOf(org.springframework.dao.DataAccessException.class);
verifyNoInteractions(audit);
verify(db,never()).insert(endsWith(".semaphor"),anyMap());
}
@Test void exceedingCourierLimitNeedsBothRight39AndAnExplicitConfirmation() throws Exception {
var json=(com.fasterxml.jackson.databind.node.ObjectNode)JSON.valueToTree(quote());
json.put("courierLimitExceeded",true);json.put("courierLimit",70);
when(pricing.calculate(any(),any(),anyMap(),any(),nullable(OrderRoutingService.Result.class),anyList())).thenReturn(JSON.treeToValue(json,Quote.class));
assertThatThrownBy(() -> service.create(USER,request())).hasMessageContaining("ausdrücklich bestätigen");
var withoutOverride=new LegacyUser(11,12,7,"entry","1".repeat(39)+"0");
when(catalog.headquarters(withoutOverride)).thenReturn(fields("hq_workmode",1,"hq_invmode",1));
assertThatThrownBy(() -> service.create(withoutOverride,with(request(),"confirmCourierLimit",true))).isInstanceOf(AccessDeniedException.class);
verify(db,never()).insert(anyString(),anyMap());
service.create(USER,with(request(),"confirmCourierLimit",true));
verify(audit).enqueueLimit(eq(101L),eq(USER),any(),anyString(),any(),any());
}
@ParameterizedTest
@CsvSource({"IMMEDIATE,1,false,false,false,9","IMMEDIATE,2,false,false,false,8","IMMEDIATE,1,true,false,false,0",
"RESERVATION,2,true,false,false,8","RESERVATION,2,true,true,false,0","RECURRING,1,true,false,false,0",
"RETROSPECTIVE,2,true,false,false,2","IMMEDIATE,2,false,false,true,9"})
void statusMatchesPhpEntryBranch(Mode mode,int workMode,boolean vehicle,boolean immediate,boolean manual,int expected) {
assertThat(OrderEntryService.initialStatus(mode,workMode,vehicle,immediate,manual)).isEqualTo(expected);
}
@Test void photoWorkflowsAreQueuedAtomicallyAndHoldDirectDispatchUntilPublished() throws Exception {
var tree=JSON.valueToTree(quote());((com.fasterxml.jackson.databind.node.ObjectNode)tree).set("photos",JSON.valueToTree(List.of(new PhotoChoice(1,2,"Nachweis"),new PhotoChoice(2,0,"Ohne Foto"))));
when(pricing.calculate(any(),any(),anyMap(),any(),nullable(OrderRoutingService.Result.class),anyList())).thenReturn(JSON.treeToValue(tree,Quote.class));
when(photos.pending(anyLong())).thenReturn(true);
var created=service.create(USER,with(request(),"courierVehicleId",52));
assertThat(created.photoSyncPending()).isTrue();assertThat(created.legacyStatus()).isEqualTo(9);
assertThat(jobs.getFirst()).containsEntry("jb_status",9).containsEntry("cr_id_order",61L);
var choices=org.mockito.ArgumentCaptor.forClass(List.class);
verify(photos).enqueue(eq(created.id()),choices.capture(),eq(0),eq(61L),eq(7L),isNull());
List<OrderPhotoWorkflows.Selection> selections=choices.getValue();
assertThat(selections).extracting(OrderPhotoWorkflows.Selection::number).containsExactly(2,0);
assertThat(selections).allMatch(selection -> selection.jbId()==created.id() && selection.trId()>created.id());
verify(db,never()).update(startsWith("UPDATE courier"),any(Object[].class));
var order=inOrder(db,photos);order.verify(db).writeTransaction(any());order.verify(photos).enqueue(anyLong(),anyList(),anyInt(),anyLong(),anyLong(),isNull());order.verify(photos).tryPublish(created.id());
}
@Test void computedForeignHeadquartersAndLonghaulMetadataAreStoredWithoutChangingOwner() throws Exception {
var tree=(com.fasterxml.jackson.databind.node.ObjectNode)JSON.valueToTree(quote());
tree.set("route",JSON.valueToTree(new RouteInfo(new java.math.BigDecimal("150"),new java.math.BigDecimal("170"),3600,8,"B",9,true,true,List.of())));
tree.set("calculation",JSON.valueToTree(List.of()));tree.put("calculatedBase",200);
when(pricing.calculate(any(),any(),anyMap(),any(),nullable(OrderRoutingService.Result.class),anyList())).thenReturn(JSON.treeToValue(tree,Quote.class));
service.create(USER,request());
assertThat(jobs.getFirst()).containsEntry("hq_id",7L).containsEntry("hq_id_dispo",8L).containsEntry("jb_longhaul",1).containsEntry("jb_status",9).containsEntry("jb_cr_subprice",java.math.BigDecimal.ZERO);
assertThat(jobs.getFirst().get("jb_cr_filter_opt").toString()).contains("BWV");
verify(db).update(startsWith("INSERT INTO jobprice"),eq(101L),eq(11),argThat((Object value) -> value instanceof java.math.BigDecimal n && n.compareTo(new java.math.BigDecimal("170"))==0),eq(""));
verify(db).insert(eq("genericdatacontainer"),argThat(fields -> "ng_route_calculation".equals(fields.get("gdc_gen_fieldname"))));
}
@Test void zonalDetailsRetainPhpMarkersWithoutAddingChargesAgain() throws Exception {
var tree=(com.fasterxml.jackson.databind.node.ObjectNode)JSON.valueToTree(quote());
tree.set("calculation",JSON.valueToTree(List.of(new CalculationLine("Außenzone",new java.math.BigDecimal("100"),java.math.BigDecimal.TWO,new java.math.BigDecimal("200")))));
when(pricing.calculate(any(),any(),anyMap(),any(),nullable(OrderRoutingService.Result.class),anyList())).thenReturn(JSON.treeToValue(tree,Quote.class));
service.create(USER,with(request(),"pricing.mode","ZONE"));
verify(db).update(contains("0,'z',0,0)"),eq(101L),eq(41L),eq(1),contains("Außenzone"));
}
@Test void cashPhotoOptionsBelongToActualCustomerEvenWhenPricingUsesSystemCashPayer() {
when(catalog.parameter(7,"CSC_ID_PAYER_CASH")).thenReturn("99");
when(catalog.costCenter(USER,99,false)).thenReturn(fields("cs_id",88,"hq_id",7));
var input=with(with(request(),"payment","CASH"),"cashStop",1);
service.quote(USER,input);
verify(photos).resolve(input,31);
}
@Test void legacyEntryFieldsAndSingleStopArePersisted() {
var input=with(with(with(with(with(with(request(),"stops",List.of(request().stops().getFirst())),"origin",6),"originOther","Empfang"),"toll",5),"invoiceOnlyText","Nur Rechnung"),"cancelIfUnavailable",true);
service.create(USER,input);
assertThat(jobs.getFirst()).containsEntry("jb_origin",6).containsEntry("jb_waitstorno",1);
assertThat(stops).hasSize(1);assertThat(stops.getFirst().get("tr_remark").toString()).contains("Maut / Auslagen: 5.00 EUR");
verify(db).insert(eq("genericdatacontainer"),argThat(row -> "jb_hiddenFreetext_1".equals(row.get("gdc_gen_fieldname")) && "Nur Rechnung".equals(row.get("gdc_content"))));
verify(db).insert(eq("genericdatacontainer"),argThat(row -> "jb_origin_other".equals(row.get("gdc_gen_fieldname")) && "Empfang".equals(row.get("gdc_content"))));
}
@Test void missingOrdererMandatoryReferenceAndUnsupportedOriginCannotBeSaved() {
assertThatThrownBy(() -> service.create(USER,with(request(),"orderer"," "))).hasMessageContaining("orderer");
assertThatThrownBy(() -> service.create(USER,with(request(),"origin",3))).hasMessageContaining("Auftragseingang");
when(catalog.costCenter(USER,41,true)).thenReturn(fields("cs_id",31,"cs_jb_commission_no",1));
when(catalog.enabled(7,"MASK_COMMISSION_NO_MANDATORY")).thenReturn(true);
assertThatThrownBy(() -> service.create(USER,request())).hasMessageContaining("Auftragsreferenz");
verify(db,never()).insert(anyString(),anyMap());
}
@Test void copiesAreOneTransactionWithOneStableGroupAndAllPhotosPublished() {
var input=with(request(),"copies",3);var result=service.create(USER,input);
assertThat(jobs).hasSize(3);assertThat(stops).hasSize(6);assertThat(result.orderIds()).hasSize(3).doesNotHaveDuplicates();
verify(db,times(1)).writeTransaction(any());
for(long id:result.orderIds())verify(photos).tryPublish(id);
verify(db).insert(eq("genericdatacontainer"),argThat(row -> "ng_created_group".equals(row.get("gdc_gen_fieldname"))));
}
@Test void allCouriersWritesExclusionsBeforeCommitWithoutOccupyingCourierZero() {
when(catalog.parameter(0,"GLOBAL_NATIONALITY_WHITELIST")).thenReturn("FR=DE,FR");
when(catalog.costCenter(USER,41,true)).thenReturn(fields("cs_id",31,"cmp_stax_idno","TEST"));
when(db.queryForList(contains("u.usr_country"),eq(7L))).thenReturn(List.of(fields("cr_id",61,"usr_country","XX")));
var station=JSON.convertValue(request().stops().getFirst(),com.fasterxml.jackson.databind.node.ObjectNode.class);station.put("country","FR");
var input=with(with(request(),"stops",List.of(station)),"allCouriers",true);
service.create(USER,input);
assertThat(jobs.getFirst()).containsEntry("jb_status",0).containsEntry("jb_globaljob",1);
verify(db).insert(eq("genericdatacontainer"),argThat(row -> "global_job_excluded_cr_list".equals(row.get("gdc_gen_fieldname")) && "61".equals(row.get("gdc_content"))));
verify(db,never()).update(startsWith("UPDATE courier"),any(Object[].class));
}
@Test void allOccurrencesUsesHolidayCalendarAndLinksEachSeriesSeparately() {
when(db.queryForList(contains("FROM publicholiday"),eq(7L),eq(2026),eq(2026))).thenReturn(List.of(fields("ph_year",2026,"ph_month",10,"ph_day",15)));
Request input=with(with(with(with(with(request(),"mode","RECURRING"),"pickupAt","2026-10-14T08:00:00"),"recurrence",Map.of("rhythm",1,"weekdays","1111100","endDate","2026-10-16")),"allOccurrences",true),"copies",2);
var quote=service.quote(USER,input);assertThat(quote.schedule()).hasSize(2);
var created=service.create(USER,with(input,"quoteFingerprint",quote.fingerprint()));
assertThat(created.orderIds()).hasSize(4);
assertThat(jobs).extracting(row -> row.get("jb_ordertime")).containsExactly(LocalDateTime.parse("2026-10-14T08:00:00"),LocalDateTime.parse("2026-10-14T08:00:00"),LocalDateTime.parse("2026-10-16T08:00:00"),LocalDateTime.parse("2026-10-16T08:00:00"));
verify(db).update(startsWith("UPDATE job SET jb_id_copy="),eq(created.orderIds().get(2)),eq(created.orderIds().get(2)),eq(created.orderIds().get(0)),eq(7L));
verify(db).update(startsWith("UPDATE job SET jb_id_copy="),eq(created.orderIds().get(3)),eq(created.orderIds().get(3)),eq(created.orderIds().get(1)),eq(7L));
}
@Test void templateCollisionNeverSilentlyOverwritesAnotherTemplate() {
when(db.queryForList(startsWith("SELECT jb_id FROM job WHERE hq_id="),any(Object[].class))).thenReturn(List.of(fields("jb_id",88)));
assertThatThrownBy(() -> service.create(USER,with(request(),"tourName","Frühtour"))).hasMessageContaining("bereits vergeben");
verify(db,never()).insert(eq("job"),anyMap());
service.create(USER,with(with(request(),"tourName","Frühtour"),"overwriteTemplateId",88));
verify(db).update(startsWith("UPDATE job SET jb_tourname=NULL"),eq(88L),eq(7L));
}
@Test void unverifiedAddressOverrideIsExplicitAndRetained() {
when(routing.geocode(any())).thenThrow(OrderEntryCatalog.invalid("Adresse unklar"));
assertThatThrownBy(() -> service.create(USER,request())).hasMessageContaining("Adresse unklar");
service.create(USER,with(request(),"acceptUnverifiedAddresses",true));
verify(db).insert(eq("genericdatacontainer"),argThat(row -> "ng_address_override".equals(row.get("gdc_gen_fieldname"))));
}
@Test void insuranceCreatesLinkedLegacyJobInSameTransaction() {
when(db.queryForList(contains("cv.crvh_sid=CONCAT"),eq(7L),eq("1888"))).thenReturn(List.of(fields("cr_id",88,"crvh_sid","TEST1888")));
var input=with(request(),"insurance",Map.of("goodsValue",100000,"rate",1,"specialCase",false));
service.create(USER,input);
assertThat(jobs).hasSize(2);assertThat(jobs.getLast()).containsEntry("vht_id",999).containsEntry("jb_origin",8).containsEntry("jb_status",2).containsEntry("jb_incomplete",1);
assertThat((java.math.BigDecimal)jobs.getLast().get("jb_totalprice")).isEqualByComparingTo("83.30");
verify(db).insert(eq("genericdatacontainer"),argThat(row -> "jb_id_insurance".equals(row.get("gdc_gen_fieldname")) && "100000|1|0".equals(row.get("gdc_context"))));
verify(db,times(1)).writeTransaction(any());
}
@Test void fileMetadataRejectsForeignOriginsAndBindsOnlyToCreatedStop() {
when(catalog.parameter(0,"URL_DOC_UPLOAD")).thenReturn("https://upload.example.test");
var station=(com.fasterxml.jackson.databind.node.ObjectNode)JSON.valueToTree(request().stops().getFirst());
station.set("attachments",JSON.valueToTree(List.of(Map.of("filename","Lieferschein.pdf","viewUrl","https://attacker.example/file","remark",""))));
assertThatThrownBy(() -> service.create(USER,with(request(),"stops",List.of(station)))).hasMessageContaining("Upload-Dienst");
station.set("attachments",JSON.valueToTree(List.of(Map.of("filename","Lieferschein.pdf","viewUrl","https://upload.example.test/view/123","remark","Test|Anhang"))));
service.create(USER,with(request(),"stops",List.of(station)));
verify(db).insert(eq("genericdatacontainer"),argThat(row -> "file_upload".equals(row.get("gdc_gen_fieldname")) && "tr".equals(row.get("gdc_obj_type")) && "Test Anhang|https://upload.example.test/view/123|1".equals(row.get("gdc_context"))));
}
@Test void serviceCalculatorPersistsPhpEncodingAndChargesEachPositionOnlyOnce() throws Exception {
when(catalog.services(USER,41)).thenReturn(List.of(new ServiceOption(12,14,"Tragehilfe","Stunde")));
var input=with(with(request(),"pricing.mode","SERVICES"),"services",List.of(Map.of("serviceId",12,"typeId",14,"quantity",2.5)));
service.create(USER,input);
assertThat(jobs.getFirst()).containsEntry("jb_freetext_2","Tragehilfe²Stunde²2.5²100");
verify(db).update(contains("0,0,'Fixpreis',0,'',?,0)"),eq(101L),eq(41L),eq(quote().components().getFirst().net()));
verify(db,never()).update(contains("VALUES (?,?,?,0,?,0,?,?,?)"),eq(101L),eq(41L),eq(0),eq("Fixpreis"),any(),any(),any());
}
@Test void courierSubordersAreCommittedAndReplayedAsOneRequest() {
when(catalog.calculatorPayer(USER)).thenReturn(99L);
when(catalog.vehicle(USER,53)).thenReturn(fields("cr_id",62,"crvh_sid","702","vht_id",2));
var input=with(with(with(with(request(),"mode","RETROSPECTIVE"),"pickupAt","2026-10-10T12:30:00"),"courierVehicleId",52),"courierShares",List.of(new CourierShare(53,new java.math.BigDecimal("40"))));
var quoted=service.quote(USER,input);input=with(input,"quoteFingerprint",quoted.fingerprint());
var created=service.create(USER,input);
assertThat(jobs).hasSize(3);
assertThat(jobs.get(1).get("jb_id_parent")).isEqualTo(created.id());
assertThat(jobs.get(2).get("jb_totalprice")).isEqualTo(((java.math.BigDecimal)jobs.get(1).get("jb_totalprice")).negate());
verify(audit,times(3)).enqueue(anyLong(),eq(USER),any(),eq(Mode.RETROSPECTIVE),anyString());
previous=List.of(Map.of("sp_context","7|"+OrderEntryService.requestHash(input)+"|"+created.id()));
assertThat(service.create(USER,input).replayed()).isTrue();assertThat(jobs).hasSize(3);
}
@Test void inventoryFailureRollsBackThroughTheOrderTransactionBeforePublishing() {
var item=new ArticleItem(3,1,1,"","",null);
when(db.queryForList(startsWith("SELECT * FROM article"),eq(3L),eq(7L))).thenReturn(List.of(fields("at_id",3,"at_name","Artikel","at_totalweight",1,"at_packingpieces",1)));
when(db.lockRows(startsWith("SELECT * FROM article"),eq(3L),eq(7L))).thenReturn(List.of(fields("at_id",3,"at_name","Artikel","at_totalweight",1,"at_packingpieces",1)));
when(db.queryForList(contains("information_schema.TABLES"),eq("article"))).thenReturn(List.of(fields("ENGINE","InnoDB")));
when(db.queryForList(contains("information_schema.TABLES"),eq("tourarticle"))).thenReturn(List.of(fields("ENGINE","InnoDB")));
var input=with(request(),"articles",List.of(item));var quoted=service.quote(USER,input);var command=with(input,"quoteFingerprint",quoted.fingerprint());
when(db.insert(eq("tourarticle"),anyMap())).thenThrow(new org.springframework.dao.DataIntegrityViolationException("fixture stock failure"));
assertThatThrownBy(() -> service.create(USER,command)).isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
verify(db).writeTransaction(any());verify(audit,never()).tryPublish(anyLong());
verify(db,never()).insert(endsWith(".semaphor"),anyMap());
}
@Test void persistsManualSustainabilityAndHistoricalCommissionInLegacyPriceRows() {
var sustainability=new OrderSustainability(catalog,routing,mock(OrderIntegrationClient.class),"","","");
org.springframework.test.util.ReflectionTestUtils.setField(service,"sustainability",sustainability);
when(catalog.costCenter(USER,41,true)).thenReturn(fields("cs_id",31,"hq_id",7,"cmp_sustainability_email","test@example.test"));
when(db.queryForList(startsWith("SELECT srvprch_price FROM servicepricehistory"),eq(52L),any())).thenReturn(List.of(fields("srvprch_price",new java.math.BigDecimal("3.5"))));
var input=with(with(with(with(request(),"mode","RETROSPECTIVE"),"pickupAt","2026-10-11T10:00:00"),"courierVehicleId",52),"sustainability",new SustainabilityInput(SustainabilityMode.MANUAL,new java.math.BigDecimal("12.5"),new java.math.BigDecimal("2.3")));
var quote=service.quote(USER,input);var checked=with(input,"quoteFingerprint",quote.fingerprint());service.create(USER,checked);
verify(db).update(startsWith("INSERT INTO jobprice"),anyLong(),eq(11),eq(new java.math.BigDecimal("12.5")),eq("m"));
verify(db).update(startsWith("INSERT INTO jobprice"),anyLong(),eq(12),eq(new java.math.BigDecimal("2.3")),eq("m"));
verify(db).update(startsWith("INSERT INTO jobprice"),anyLong(),eq(14),eq(new java.math.BigDecimal("3.5")),eq("crvh_partner_commission"));
assertThat(jobs.getFirst()).containsEntry("jb_incomplete",0);
}
@Test void rejectsInvalidFreeArticleAndSustainabilityValuesBeforeAnyWrite() {
try(var factory=Validation.buildDefaultValidatorFactory()) {
var validator=factory.getValidator();
assertThat(validator.validate(with(request(),"articles",List.of(new ArticleItem(0,1,1,"","",null,new FreeArticle("",new java.math.BigDecimal("-1"),128,new java.math.BigDecimal("-1"))))))).hasSizeGreaterThanOrEqualTo(4);
assertThat(validator.validate(with(request(),"sustainability",new SustainabilityInput(null,new java.math.BigDecimal("0"),new java.math.BigDecimal("1000000"))))).hasSizeGreaterThanOrEqualTo(3);
}
verify(db,never()).writeTransaction(any());
}
@Test void foreignEntryLocksOriginAccountAndWritesSelectedHeadquarters() {
var selected=new LegacyUser(USER.userId(),USER.employeeId(),8,USER.username(),USER.rights(),7);
when(catalog.headquarters(selected)).thenReturn(fields("hq_workmode",1,"hq_invmode",1));
when(catalog.costCenter(selected,41,true)).thenReturn(fields("cs_id",31,"hq_id",8,"cmp_postage",0));
service.create(selected,request());
verify(db).lockRows("SELECT usr_id FROM user WHERE usr_id=? AND hq_id=?",11L,7L);
assertThat(jobs.getFirst()).containsEntry("hq_id",8L);
}
}
@@ -0,0 +1,38 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import java.util.*;
import java.util.function.Supplier;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static org.assertj.core.api.Assertions.*;
import static de.stadtbote.votian.OrderEntryFixtures.*;
import static de.stadtbote.votian.OrderEntryService.fields;
import static de.stadtbote.votian.OrderEntryModels.*;
class OrderEntryTemplatesTest {
final DatabaseService db=mock(DatabaseService.class);
final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);
final OrderEntryTemplates templates=new OrderEntryTemplates(db,catalog);
@Test void copyUsesScopedOrderAndRestoresLegacyServiceQuantitiesWithoutDispatchState() {
when(db.readTransaction(any())).thenAnswer(call -> ((Supplier<?>)call.getArgument(0)).get());
when(db.queryForList("SELECT * FROM job WHERE jb_id=? AND hq_id=?",88L,7L)).thenReturn(List.of(fields("jb_id",88,"csc_id_payer",41,"jb_version",1,"jb_status",2,"cr_id",900,"jb_freetext_2","Tragehilfe²Stunde²2.5²75.00")));
when(catalog.costCenter(USER,41,true)).thenReturn(fields("csc_id",41,"cs_id",31));
when(catalog.services(USER,41)).thenReturn(List.of(new ServiceOption(12,14,"Tragehilfe","Stunde")));
when(db.queryForList(contains("SELECT t.*"),eq(88L))).thenReturn(List.of(fields("tr_comp","Lager","tr_ware_from_to",2,"ad_country","DE")));
when(db.queryForList(contains("FROM tourarticle ta"),eq(88L))).thenReturn(List.of(fields("at_id",3,"tr_sort",1,"trat_quantity",2,"trat_remark","Vorsichtig"),fields("at_id",0,"tr_sort",2,"trat_quantity",3,"trat_name","Freie Kiste","trat_weight",12.5,"trat_price",24,"trat_packingpieces",2)));
when(db.queryForList(startsWith("SELECT * FROM article"),eq(3L),eq(7L))).thenReturn(List.of(fields("at_id",3,"at_name","Artikel","at_serialno",1)));
var result=templates.load(new UsernamePasswordAuthenticationToken(USER,null,List.of()),88);
assertThat((List<Map<String,Object>>)result.get("articles")).hasSize(2).allSatisfy(a -> assertThat(a).doesNotContainKeys("booking","serialNumber"));
var free=(FreeArticle)((List<Map<String,Object>>)result.get("articles")).getLast().get("freeArticle");
assertThat(free.name()).isEqualTo("Freie Kiste");assertThat(free.totalWeight()).isEqualByComparingTo("12.5");
assertThat(result.get("sourceOrderId")).isEqualTo(88L);
assertThat((List<ServiceItem>)result.get("services")).containsExactly(new ServiceItem(12,14,new java.math.BigDecimal("2.5")));
assertThat(result).doesNotContainKeys("status","courierVehicleId","insurance","pickupAt","requestId");
verify(db,never()).insert(anyString(),anyMap());
}
@Test void foreignOrderCannotBeLoaded() {
when(db.readTransaction(any())).thenAnswer(call -> ((Supplier<?>)call.getArgument(0)).get());
assertThatThrownBy(() -> templates.load(new UsernamePasswordAuthenticationToken(USER,null,List.of()),88)).hasMessageContaining("Niederlassung");
}
}
@@ -0,0 +1,40 @@
package de.stadtbote.votian;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.*;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
import static de.stadtbote.votian.OrderEntryFixtures.JSON;
class OrderIntegrationClientTest {
HttpServer server;
String url;
final OrderIntegrationClient client=new OrderIntegrationClient(JSON);
@BeforeEach void start() throws Exception {server=HttpServer.create(new InetSocketAddress("127.0.0.1",0),0);server.start();url="http://127.0.0.1:"+server.getAddress().getPort();}
@AfterEach void stop(){server.stop(0);}
@Test void postsContractJsonAndAcceptsEmptySuccessfulAcknowledgement() {
server.createContext("/selection",exchange -> {
assertThat(exchange.getRequestMethod()).isEqualTo("POST");
assertThat(JSON.readTree(exchange.getRequestBody()).path("trId").asLong()).isEqualTo(201);
assertThat(exchange.getRequestHeaders().getFirst("Content-Type")).isEqualTo("application/json");
exchange.sendResponseHeaders(204,-1);exchange.close();
});
assertThat(client.post(url+"/selection",Map.of("jbId",101,"trId",201,"number",0),"Fotodienst").isObject()).isTrue();
}
@Test void doesNotFollowRedirectsOrLeakRemoteBodies() {
server.createContext("/redirect",exchange -> {exchange.getResponseHeaders().add("Location",url+"/secret");exchange.sendResponseHeaders(302,-1);exchange.close();});
server.createContext("/secret",exchange -> {byte[] body="private-key".getBytes(StandardCharsets.UTF_8);exchange.sendResponseHeaders(500,body.length);exchange.getResponseBody().write(body);exchange.close();});
assertThatThrownBy(() -> client.get(url+"/redirect","Fotodienst")).hasMessageContaining("nicht verfügbar").hasMessageNotContaining("private-key");
assertThatThrownBy(() -> client.get(url+"/secret","Fotodienst")).hasMessageNotContaining("private-key");
}
@Test void rejectsOversizedResponses() {
server.createContext("/large",exchange -> {byte[] body=new byte[2_000_001];exchange.sendResponseHeaders(200,body.length);try{exchange.getResponseBody().write(body);}finally{exchange.close();}});
assertThatThrownBy(() -> client.get(url+"/large","Routendienst")).hasMessageContaining("ungültige Daten");
}
@Test void basicCredentialsAreNeverSentOverPlainHttpOrIncludedInErrors() {
assertThatThrownBy(() -> client.postAuthenticated(url,Map.of(),"CO2","private-user","private-password")).hasMessageContaining("HTTPS").hasMessageNotContaining("private-user").hasMessageNotContaining("private-password");
}
}
@@ -0,0 +1,43 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryModels.*;
class OrderMapSelectionTest {
final DatabaseService db=mock(DatabaseService.class);
final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);
final OrderIntegrationClient client=mock(OrderIntegrationClient.class);
@Test void selectedLegacyCoordinatesAreUsedWithoutGeocodingOrLosingPrecision() {
var service=new OrderRoutingService(db,catalog,client,"","","","https://maps.example.test/geocode");
var station=new Station(null,Models.StopType.PICKUP,"Test","Testweg (53.123456,9.987654)","1","20095","Hamburg","DE","","","","",null,null,null);
var point=service.geocode(station);
assertThat(point.latitude()).isEqualTo(53.123456);assertThat(point.longitude()).isEqualTo(9.987654);
verifyNoInteractions(db,catalog,client);
assertThatThrownBy(() -> OrderRoutingService.coordinates("GPS (91,0)")).hasMessageContaining("Ungültige Geokoordinaten");
}
@Test void unconfiguredReverseLookupPreservesAPointAndClearlyRequiresAddressReview() {
when(catalog.parameter(0,"GOOGLE_MAPS_API_KEY")).thenReturn("");
var service=new OrderRoutingService(db,catalog,client,"","","","https://maps.example.test/geocode");
var address=service.reverse(new MapPoint(new BigDecimal("0"),new BigDecimal("0")));
assertThat(address.street()).isEqualTo("GPS (0,0)");assertThat(address.warning()).contains("GOOGLE_MAPS_API_KEY");
assertThat(address.country()).isEmpty();verifyNoInteractions(client);
}
@Test void reverseGeocoderCopiesAddressComponentsAndFitsTheLegacyStreetColumn() throws Exception {
var service=new OrderRoutingService(db,catalog,client,"","","fixture-key","https://maps.example.test/geocode");
when(client.get(anyString(),anyString())).thenReturn(new ObjectMapper().readTree("""
{"status":"OK","results":[{"address_components":[
{"types":["route"],"long_name":"Eine sehr lange Straße mit einem Namen über fünfzig Zeichen hinaus"},
{"types":["street_number"],"long_name":"12"}, {"types":["postal_code"],"long_name":"20095"},
{"types":["locality"],"long_name":"Hamburg"}, {"types":["country"],"short_name":"DE"}]}]}
"""));
var address=service.reverse(new MapPoint(new BigDecimal("53.123456"),new BigDecimal("9.987654")));
assertThat(address.street()).hasSize(50).endsWith(" (53.123456,9.987654)");
assertThat(address.country()).isEqualTo("DE");assertThat(address.houseNumber()).isEqualTo("12");assertThat(address.postalCode()).isEqualTo("20095");
}
}
@@ -0,0 +1,38 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryService.fields;
class OrderPaymentTermsTest {
final DatabaseService db=mock(DatabaseService.class);
@Test void capturesWithoutReusingTheKilometerPriceType() {
when(db.queryForList(startsWith("SELECT cs.cs_payment_term"),any(Object[].class))).thenReturn(List.of(fields("cs_payment_term",10,"mt_value","10 Tage netto")));
OrderPaymentTerms.capture(db,7,8);
verify(db).insert(eq("genericdatacontainer"),argThat(r->r.get("gdc_obj_id").equals(7L)&&r.get("gdc_content").equals("10 Tage netto")&&r.get("gdc_context").equals("10")));
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void storedTermWinsOverCurrentCustomerAndAmbiguousPhpRows() {
when(db.queryForList(startsWith("SELECT gdc_content AS label"),any(Object[].class))).thenReturn(List.of(fields("code","10","label","Historischer Text")));
assertThat(OrderPaymentTerms.read(db,7)).containsEntry("label","Historischer Text");
verify(db,never()).queryForList(contains("FROM customer"),any(Object[].class));
verify(db,never()).queryForList(contains("FROM jobprice"),any(Object[].class));
}
@Test void phpFallbackRequiresARemarkAndRejectsMultipleCandidates() {
when(db.queryForList(contains("FROM jobprice"),any(Object[].class))).thenReturn(List.of(fields("code",10,"label","Alt")));
assertThat(OrderPaymentTerms.read(db,7)).containsEntry("label","Alt");
verify(db).queryForList(contains("TRIM(COALESCE(jbprc_remark,''))<>''"),eq(7L));
when(db.queryForList(contains("FROM jobprice"),any(Object[].class))).thenReturn(List.of(fields("code",10,"label","Alt"),fields("code",20,"label","Anders")));
assertThatThrownBy(()->OrderPaymentTerms.read(db,7)).hasMessageContaining("Mehrdeutige");
}
@Test void missingSnapshotIsExplicitAndCreditCopiesTheOriginal() {
assertThat(OrderPaymentTerms.read(db,7)).containsEntry("source","Nicht im Auftrag gespeichert");
when(db.queryForList(startsWith("SELECT gdc_content,gdc_context"),any(Object[].class))).thenReturn(List.of(fields("gdc_content","Alt","gdc_context","3")));
OrderPaymentTerms.copy(db,7,9);
verify(db).insert(eq("genericdatacontainer"),argThat(r->r.get("gdc_obj_id").equals(9L)&&r.get("gdc_content").equals("Alt")));
}
}
@@ -0,0 +1,135 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import java.util.*;
import java.util.function.Supplier;
import static de.stadtbote.votian.OrderEntryFixtures.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryService.fields;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class OrderPhotoWorkflowsTest {
final DatabaseService db=mock(DatabaseService.class);
final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);
final OrderIntegrationClient client=mock(OrderIntegrationClient.class);
final OrderPhotoWorkflows photos=new OrderPhotoWorkflows(db,catalog,client,JSON);
@BeforeEach void setup() throws Exception {
when(catalog.parameter(0,"URL_CS_PHOTO_WORKFLOW")).thenReturn("http://photo.test/");
when(catalog.costCenter(USER,41,false)).thenReturn(fields("cs_id",31));
when(client.get(contains("/sets/kunde/31"),anyString())).thenReturn(JSON.readTree("""
{"result":[{"number":1,"name":"Standard","default":true,"workflows":[{"number":0,"name":"Ohne Foto","default":false},{"number":3,"name":"Nachweis","default":true}]}]}
"""));
when(db.update(anyString(),any(Object[].class))).thenReturn(1);
when(db.writeTransaction(any())).thenAnswer(call -> ((Supplier<?>)call.getArgument(0)).get());
when(db.lockRows(anyString(),any(Object[].class))).thenReturn(List.of(fields("jb_id",101,"jb_status",9,"jb_version",0,"jb_storno",0)));
when(client.get(contains("/selection?jbId=101"),anyString())).thenReturn(JSON.readTree("{\"result\":[]}"));
}
@Test void looksUpCustomerBehindScopedCostCenterAndPreservesWorkflowZero() throws Exception {
assertThat(photos.options(USER,41).sets().getFirst().workflows()).hasSize(2);
verify(catalog).costCenter(USER,41,false);
var tree=JSON.valueToTree(request());((com.fasterxml.jackson.databind.node.ObjectNode)tree.path("stops").get(0)).put("photoWorkflow",0);
var choices=photos.resolve(JSON.treeToValue(tree,Request.class),31);
assertThat(choices.choices()).containsExactly(new PhotoChoice(1,0,"Ohne Foto"),new PhotoChoice(2,3,"Nachweis"));
}
@Test void rejectsSelectionsFromOtherCustomersAndAmbiguousDefaults() throws Exception {
var tree=JSON.valueToTree(request());((com.fasterxml.jackson.databind.node.ObjectNode)tree.path("stops").get(0)).put("photoWorkflow",7);
assertThatThrownBy(() -> photos.resolve(JSON.treeToValue(tree,Request.class),31)).hasMessageContaining("Fotoauswahl");
when(client.get(contains("/sets/"),anyString())).thenReturn(JSON.readTree("{\"result\":[{\"number\":1,\"name\":\"A\",\"default\":true,\"workflows\":[]},{\"number\":2,\"name\":\"B\",\"default\":true,\"workflows\":[]}]}"));
assertThatThrownBy(() -> photos.resolve(request(),31)).hasMessageContaining("mehrere Standardsets");
}
@Test void serviceFailureCannotSilentlyRemoveRequiredPhotography() {
when(client.get(contains("/sets/"),anyString())).thenThrow(OrderIntegrationClient.unavailable("Fotodienst ausgefallen"));
assertThatThrownBy(() -> photos.resolve(request(),31)).hasMessageContaining("Fotodienst ausgefallen");
verify(db,never()).insert(anyString(),anyMap());
}
private void pendingTransfer() throws Exception {
var transfer=new OrderPhotoWorkflows.Transfer(List.of(new OrderPhotoWorkflows.Selection(101,201,3),new OrderPhotoWorkflows.Selection(101,202,0)),0,61,7);
when(db.queryForList(startsWith("SELECT gdc_id,gdc_content"),any(Object[].class))).thenReturn(List.of(fields("gdc_id",500,"gdc_content",JSON.writeValueAsString(transfer))));
}
@Test void publicationReusesConfirmedSelectionAndReleasesOriginalVehicleOnlyAfterTransfer() throws Exception {
pendingTransfer();when(client.get(contains("/selection?"),anyString())).thenReturn(JSON.readTree("{\"result\":[{\"tr_id\":201,\"number\":3}]}"));
photos.publish(101);
verify(client,never()).post(anyString(),eq(new OrderPhotoWorkflows.Selection(101,201,3)),anyString());
var sequence=inOrder(client,db);
sequence.verify(client).post(eq("http://photo.test/selection"),eq(new OrderPhotoWorkflows.Selection(101,202,0)),anyString());
sequence.verify(db).update(startsWith("UPDATE genericdatacontainer SET gdc_context='SENT'"),eq(500L),startsWith("SENDING:"));
sequence.verify(db).update(startsWith("UPDATE job SET jb_status"),eq(0),eq(101L));
sequence.verify(db).update(startsWith("UPDATE courier"),eq(61L),eq(7L));
}
@Test void failedPhotoWriteRemainsPendingAndNeverReleasesDisposition() throws Exception {
pendingTransfer();when(client.post(anyString(),any(),anyString())).thenThrow(OrderIntegrationClient.unavailable("offline"));
photos.tryPublish(101);
verify(db,never()).update(startsWith("UPDATE job"),any(Object[].class));
verify(db,never()).update(startsWith("UPDATE genericdatacontainer SET gdc_context='SENT'"),any(Object[].class));
verify(db).update(startsWith("UPDATE genericdatacontainer SET gdc_context='PENDING'"),eq(500L),startsWith("SENDING:"));
}
@Test void laterOperatorChangesAreNotOverwrittenOnRetry() throws Exception {
pendingTransfer();when(db.lockRows(anyString(),any(Object[].class))).thenReturn(List.of(fields("jb_status",9,"jb_version",1)));
photos.publish(101);
verify(db,never()).update(startsWith("UPDATE job"),any(Object[].class));
verify(db,never()).update(startsWith("UPDATE courier"),any(Object[].class));
verify(db).update(startsWith("UPDATE genericdatacontainer SET gdc_context='SENT'"),eq(500L),startsWith("SENDING:"));
}
@Test void lostLeaseDoesNotPostOrReleaseAnOrder() throws Exception {
pendingTransfer();when(db.update(startsWith("UPDATE genericdatacontainer SET gdc_context=? WHERE gdc_id=? AND"),any(Object[].class))).thenReturn(0);
photos.tryPublish(101);
verify(client,never()).post(anyString(),any(),anyString());
verify(db,never()).update(startsWith("UPDATE job"),any(Object[].class));
}
@Test void httpCallsRunOutsideDatabaseTransactions() throws Exception {
pendingTransfer();var inTransaction=new java.util.concurrent.atomic.AtomicBoolean();
doAnswer(call -> {assertThat(inTransaction.getAndSet(true)).isFalse();try{return ((Supplier<?>)call.getArgument(0)).get();}finally{inTransaction.set(false);}}).when(db).writeTransaction(any());
when(client.get(contains("/selection?"),anyString())).thenAnswer(call -> {assertThat(inTransaction.get()).isFalse();return JSON.readTree("{\"result\":[]}");});
when(client.post(anyString(),any(),anyString())).thenAnswer(call -> {assertThat(inTransaction.get()).isFalse();return JSON.createObjectNode();});
photos.publish(101);
verify(client,times(2)).post(anyString(),any(),anyString());
}
@Test void filtersDisallowedSetsAndFlowsAndUsesPositionBeforeDefault() throws Exception {
when(client.get(contains("/sets/"),anyString())).thenReturn(JSON.readTree("""
{"result":[{"number":99,"name":"Gesperrt","allowed":false},{"number":2,"name":"Set B","workflows":[
{"number":7,"name":"Verboten","allowed":false},
{"number":0,"name":"Keine Pflicht","default":true,"min":0,"max":5},
{"number":3,"name":"Letzte Station","positions":["LAST"],"min":1,"max":5}]}]}
"""));
var plan=photos.resolve(request(),31);assertThat(plan.set().number()).isEqualTo(2);
assertThat(plan.set().workflows()).extracting(PhotoWorkflow::number).containsExactly(0,3);
assertThat(plan.choices()).containsExactly(new PhotoChoice(1,0,"Keine Pflicht"),new PhotoChoice(2,3,"Letzte Station"));
var tree=(com.fasterxml.jackson.databind.node.ObjectNode)JSON.valueToTree(request());tree.put("photoSet",99);
assertThatThrownBy(()->photos.resolve(JSON.treeToValue(tree,Request.class),31)).hasMessageContaining("nicht mehr freigegeben");
tree.put("photoSet",2);((com.fasterxml.jackson.databind.node.ObjectNode)tree.path("stops").get(0)).put("photoWorkflow",7);
assertThatThrownBy(()->photos.resolve(JSON.treeToValue(tree,Request.class),31)).hasMessageContaining("Fotoauswahl");
}
@Test void singleStationUsesLastAndEmptyCustomerSetsUseGlobalFallback() throws Exception {
when(client.get(contains("/sets/kunde/31"),anyString())).thenReturn(JSON.readTree("{\"result\":[]}"));
when(client.get(contains("/sets/kunde/0"),anyString())).thenReturn(JSON.readTree("""
{"result":[{"number":4,"name":"Allgemein","workflows":[{"number":1,"name":"Start","positions":["FIRST"]},{"number":2,"name":"Ziel","positions":["LAST"]}]}]}
"""));
var tree=(com.fasterxml.jackson.databind.node.ObjectNode)JSON.valueToTree(request());((com.fasterxml.jackson.databind.node.ArrayNode)tree.path("stops")).remove(1);
assertThat(photos.resolve(JSON.treeToValue(tree,Request.class),31).choices()).containsExactly(new PhotoChoice(1,2,"Ziel"));
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({"ALL,PICKUP,true","PICKUP_ONLY,PICKUP,false","PICKUP_ONLY,DELIVERY,true","DELIVERY_ONLY,PICKUP,true","DELIVERY_ONLY,DELIVERY,false","EXCEPT_PICKUP,PICKUP,true","EXCEPT_DELIVERY,DELIVERY,true","EXCEPT_DELIVERY,PICKUP,false","PICKUP_ONLY,BOTH,true","EXCEPT_PICKUP,BOTH,true"})
void stationScopesMatchCurrentPhpWareFlags(String scope,Models.StopType type,boolean expected){
assertThat(OrderPhotoWorkflows.offered(new PhotoWorkflow(1,"A",false,0,5,scope,List.of()),type)).isEqualTo(expected);
}
@Test void setIsAssignedBeforeReadingAndPostingOverridesAndSurvivesRetry() throws Exception {
var transfer=new OrderPhotoWorkflows.Transfer(List.of(new OrderPhotoWorkflows.Selection(101,201,3)),0,61,7,4);
when(db.queryForList(startsWith("SELECT gdc_id,gdc_content"),any(Object[].class))).thenReturn(List.of(fields("gdc_id",500,"gdc_content",JSON.writeValueAsString(transfer))));
photos.publish(101);photos.publish(101);
var sequence=inOrder(client);for(int i=0;i<2;i++){
sequence.verify(client).post(eq("http://photo.test/selection/set"),eq(new OrderPhotoWorkflows.SetSelection(101,4)),anyString());
sequence.verify(client).get(eq("http://photo.test/selection?jbId=101"),anyString());
sequence.verify(client).post(eq("http://photo.test/selection"),eq(new OrderPhotoWorkflows.Selection(101,201,3)),anyString());
}
}
@Test void emptySetStillQueuesItsAssignmentAndBadMetadataDoesNotBypassPhotography() throws Exception {
photos.enqueue(101,List.of(),0,61,7,4);verify(db).insert(eq("genericdatacontainer"),argThat(values->values.get("gdc_content").toString().contains("\"setNumber\":4")));
when(client.get(contains("/sets/"),anyString())).thenReturn(JSON.readTree("{\"result\":[{\"number\":1,\"name\":\"Set\",\"workflows\":[{\"number\":3,\"name\":\"Bad\",\"min\":5,\"max\":1}]}]}"));
assertThatThrownBy(()->photos.resolve(request(),31)).hasMessageContaining("Fotomengen");
}
}
@@ -0,0 +1,74 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseService;
import jakarta.validation.Validation;
import org.junit.jupiter.api.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryService.fields;
import static de.stadtbote.votian.OrderEntryFixtures.USER;
import static de.stadtbote.votian.OrderEntryModels.*;
class OrderRecalculationTest {
final DatabaseService db=mock(DatabaseService.class);
final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);
final LegacyOrderPricing prices=mock(LegacyOrderPricing.class);
final OrderRoutingService routing=mock(OrderRoutingService.class);
final OrderOperations orders=mock(OrderOperations.class);
final BusinessSupport support=new BusinessSupport(db,new ObjectMapper().findAndRegisterModules());
final OrderRecalculation service=new OrderRecalculation(db,support,orders,catalog,prices,routing,Validation.buildDefaultValidatorFactory().getValidator());
final Map<String,Object> job=fields("jb_id",5L,"hq_id",7,"hq_id_dispo",7,"jb_ordertime",LocalDateTime.of(2026,10,10,10,0),"csc_id_payer",41,"jb_totalprice",100,"jb_cr_price",70,"jb_status",9,"jb_version",2,"jb_weight",0);
OrderOperations.Snapshot snapshot;
OrderRecalculation.Input input=new OrderRecalculation.Input(new Pricing(PriceMode.FIXED,new BigDecimal("100"),BigDecimal.TEN,null,null,null,null,false,4L),2,List.of(),false,false);
@BeforeEach void setup() {
when(db.readTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.writeTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.lockRows(anyString(),any(Object[].class))).thenReturn(List.of(fields("hq_id",7)));
when(db.lockRows(startsWith("SELECT jb_id,hq_id FROM job WHERE jb_id_parent="),any(Object[].class))).thenReturn(List.of());
when(catalog.costCenter(USER,41,true)).thenReturn(fields("cs_id",31));
snapshot=new OrderOperations.Snapshot(job,List.of(fields("tr_id",6,"tr_sort",1,"tr_ware_from_to",3,"tr_comp","Test","ad_street","Teststraße","tr_hsno","1","ad_city","Berlin","ad_zipcode","10115","ad_country","DE")),List.of(),List.of(),List.of(),List.of());
when(orders.snapshot(eq(USER),eq(5L),anyBoolean())).thenAnswer(c->snapshot);
when(orders.view(USER,5)).thenReturn(fields("id",5));
when(prices.calculate(eq(USER),any(),anyMap(),any(),any(),anyList())).thenReturn(OrderEntryFixtures.quote());
}
OrderRecalculation.Command command(String fingerprint) {return new OrderRecalculation.Command(UUID.randomUUID().toString(),AdminSupport.revision(snapshot),input,fingerprint);}
@Test void previewIsReadOnlyAndUsesSavedDateStationAndPayer() {
var result=service.preview(USER,5,command(null));assertThat(result).containsEntry("previousNet",new BigDecimal("100"));
verify(prices).calculate(eq(USER),argThat(r->r.stops().size()==1&&r.payerCostCenterId()==41&&r.pricing().mode()==PriceMode.FIXED),anyMap(),eq(LocalDateTime.of(2026,10,10,10,0)),isNull(),anyList());
verify(db,never()).update(anyString(),any(Object[].class));verify(db,never()).insert(anyString(),anyMap());
}
@Test void staleRevisionAndChangedTariffsCannotBeSaved() {
assertThatThrownBy(()->service.preview(USER,5,new OrderRecalculation.Command(null,"old",input,null))).hasMessageContaining("geändert");
assertThatThrownBy(()->service.save(USER,5,command("old"))).hasMessageContaining("Preisvorschau");
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void writesOnlyPricesAndMetadataWithoutReplayingOperationalState() {
var c=command(null);var preview=service.preview(USER,5,c);
service.save(USER,5,new OrderRecalculation.Command(c.requestId(),c.revision(),input,(String)preview.get("fingerprint")));
verify(db).update(eq("DELETE FROM tourservice WHERE jb_id=?"),eq(5L));
verify(db).update(startsWith("UPDATE job SET"),any(Object[].class));
verify(db,never()).insert(eq("job"),anyMap());verify(db,never()).insert(eq("tour"),anyMap());
verify(db,never()).update(contains("stock"),any(Object[].class));
verify(db,never()).update(contains("mt_sort=16"),any(Object[].class));
verify(db,never()).update(contains("jb_incomplete"),any(Object[].class));
}
@Test void paymentsAndBrokenChildLinksAreNotSilentlyOverwritten() {
snapshot=new OrderOperations.Snapshot(job,snapshot.stops(),List.of(),List.of(),List.of(fields("jbp_id",1)),List.of());
assertThatThrownBy(()->service.preview(USER,5,command(null))).hasMessageContaining("Einnahmenbuchungen");
snapshot=new OrderOperations.Snapshot(job,snapshot.stops(),List.of(),List.of(),List.of(),List.of());job.put("jb_id_parent",-1);
assertThatThrownBy(()->service.preview(USER,5,command(null))).hasMessageContaining("fehlende Unteraufträge");
}
@Test void invalidInputsAndMissingRightsFailBeforeExternalCalls() {
var none=new LegacyUser(11,12,7,"no-rights","0".repeat(40));
assertThatThrownBy(()->service.preview(none,5,command(null))).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);
input=new OrderRecalculation.Input(new Pricing(PriceMode.FIXED,new BigDecimal("-1"),null,null,null,null,null,false,4L),2,List.of(),false,false);
assertThatThrownBy(()->service.preview(USER,5,command(null))).hasMessageContaining("Kalkulationswerte");
verifyNoInteractions(routing);
}
}
@@ -0,0 +1,132 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseService;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.OrderEntryService.fields;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.BusinessIntegrationAssertions.*;
/** Real transactions and schema constraints in the disposable MariaDB fixture. */
public class OrderRemainderIntegrationAssertions {
static OrderRecalculation.Command calculation(OrderRecalculation service,LegacyUser u,long job,Map<String,Object> options,OrderRecalculation.Input input){var draft=new OrderRecalculation.Command(token(),text(options,"revision"),input,null);return new OrderRecalculation.Command(draft.requestId(),draft.revision(),input,text(service.preview(u,job,draft),"fingerprint"));}
static BigDecimal money(String n){return new BigDecimal(n);}
static List<OrderArticles.Position> positions(OrderArticles svc,LegacyUser u,long job){return (List<OrderArticles.Position>)svc.view(u,job).get("positions");}
static OrderArticles.Save articleSave(OrderArticles svc,LegacyUser u,long job,List<OrderArticles.Position> rows){return new OrderArticles.Save(token(),text(svc.view(u,job),"revision"),rows);}
static OrderArticles.Movement movement(OrderArticles svc,LegacyUser u,long job,long article,int quantity,long from,long to,String serial,long reverse){var m=new OrderArticles.Movement(token(),text(svc.view(u,job),"revision"),reverse,reverse>0?null:new ArticleItem(article,1,quantity,serial,"",new StockBooking(from,to)),"QA Korrektur",null);var p=svc.preview(u,job,m);return new OrderArticles.Movement(m.requestId(),m.revision(),reverse,m.item(),m.reason(),text(p,"fingerprint"));}
static long balance(DatabaseService db,long article,long stock){return db.queryForObject("SELECT COALESCE(SUM(stkat_itemquantity),0) FROM stockarticle WHERE at_id=? AND stk_id=?",Long.class,article,stock);}
static OrderArticles.Position position(long id,long station,String name,int quantity){return new OrderArticles.Position(id,station,0,name,quantity,money("2.5"),1,money("15"),"","Hinweis");}
public static void verify(DatabaseService db){
var u=AppointmentServiceTest.user(0,1,2,4,5,7,14,38,41);var json=new ObjectMapper().findAndRegisterModules();var support=new BusinessSupport(db,json);var masters=new MasterData(db,support);
var blank=masters.view(u,"customers",0,0);var input=values(blank);input.putAll(fields("cmp_comp","QA Lagerkunde","cs_eid","QAL001","ad_street","Teststraße","cmp_hsno","1","ad_zipcode","10115","ad_city","Berlin","usr_name","Test","usr_phone","030123"));var customer=masters.save(u,"customers",0,0,save(blank,input));long cs=id(customer,"id"),center=id(db.queryForList("SELECT csc_id FROM customer WHERE cs_id=?",cs).getFirst(),"csc_id"),address=id(db.queryForList("SELECT ad_id FROM company WHERE cmp_comp='QA Lagerkunde'").getFirst(),"ad_id");
var cb=masters.view(u,"couriers",0,0);var cv=values(cb);cv.putAll(fields("cmp_comp","QA Unternehmer","cr_eid","QA0001","cmp_hsno","2","usr_name","Kurier","usr_phone","030123","ad_street","Testweg","ad_zipcode","10115","ad_city","Berlin"));long cr=id(masters.save(u,"couriers",0,0,save(cb,cv)),"id");var vb=masters.view(u,"vehicles",0,cr);var vv=values(vb);vv.putAll(fields("crvh_sid","QA01","vht_id",1));long vehicle=id(masters.save(u,"vehicles",0,cr,save(vb,vv)),"id");
long job=db.writeTransaction(()->{
long j=db.insert("job",fields("hq_id",7,"csc_id_payer",center,"jb_status",1,"cr_id",cr,"cr_id_order",cr,"cr_sid","QA01","vht_id",1,"jb_createtime",LocalDateTime.now(),"jb_ordertime",LocalDateTime.now().minusHours(1),"jb_freetext_1","","jb_service","","jb_orderer","","jb_scrm","","jb_totalprice",100,"jb_cr_price",80,"jb_sales_tax_rate",19,"jb_sales_tax_rate_sign","USt","jb_payment",0,"jb_postage",0));
for(int i=1;i<=2;i++)db.insert("tour",fields("jb_id",j,"tr_sort",i,"ad_id",address,"tr_comp","Station "+i,"tr_ware_from_to",i==1?2:1,"tr_status",0,"tr_signname","","tr_cs_freetext",""));
db.insert("parameter",fields("hq_id",7,"emp_id",0,"par_key","CSC_ID_PAYER_CALCULATOR","par_value",Long.toString(center)));return j;
});
var catalog=new OrderEntryCatalog(db);var pricing=new LegacyOrderPricing(db,catalog);var ops=new OrderOperations(db,support,catalog,pricing);var validator=jakarta.validation.Validation.buildDefaultValidatorFactory().getValidator();var svc=new OrderArticles(db,support,ops,new OrderEntryInventory(db,catalog),validator);
long station=id(db.queryForList("SELECT tr_id FROM tour WHERE jb_id=? ORDER BY tr_sort",job).getFirst(),"tr_id");
var first=articleSave(svc,u,job,List.of(position(0,station,"Freier Artikel",2)));svc.save(u,job,first);svc.save(u,job,first);assertThat(positions(svc,u,job)).hasSize(1);long articleRow=positions(svc,u,job).getFirst().id();
svc.save(u,job,articleSave(svc,u,job,List.of(position(articleRow,station,"Korrigierter Artikel",3))));assertThat(positions(svc,u,job).getFirst().id()).isEqualTo(articleRow);
assertThatThrownBy(()->svc.save(u,job,new OrderArticles.Save(token(),first.revision(),List.of()))).hasMessageContaining("409");
assertThatThrownBy(()->svc.save(u,job,articleSave(svc,u,job,List.of(position(99999,station,"Fremd",1))))).hasMessageContaining("Unbekannte");
assertThatThrownBy(()->svc.save(u,job,articleSave(svc,u,job,List.of(position(articleRow,99999,"Fremd",1))))).hasMessageContaining("Station");
db.writeTransaction(()->db.insert("tourarticleprocess",fields("trat_id",articleRow,"tr_id",station,"tratp_no","SCAN","tratp_event",1)));
assertThatThrownBy(()->svc.save(u,job,articleSave(svc,u,job,List.of(position(articleRow,station,"Änderung",4))))).hasMessageContaining("gescannte");
assertThatThrownBy(()->svc.save(u,job,articleSave(svc,u,job,List.of()))).hasMessageContaining("gescannte");
svc.save(u,job,articleSave(svc,u,job,List.of(positions(svc,u,job).getFirst(),position(0,station,"Zweite Position",1))));
assertThat(db.queryForObject("SELECT COUNT(*) FROM tourarticleprocess WHERE trat_id=?",Integer.class,articleRow)).isEqualTo(1);
long article=db.writeTransaction(()->db.insert("article",fields("hq_id",7,"at_name","QA Palette","at_description","","at_mountable",0,"at_stk_itemquantity",1,"at_stk_areaquantity",1,"at_totalweight",2,"at_eid","QAA")));
long stock=db.writeTransaction(()->db.insert("stock",fields("hq_id",7,"stk_name","QA Lager","stk_obj_type","","stk_maxquantity",20,"stk_visible",1)));
var incoming=movement(svc,u,job,article,5,0,stock,"",0);svc.move(u,job,incoming);svc.move(u,job,incoming);assertThat(balance(db,article,stock)).isEqualTo(5);assertThat(db.queryForObject("SELECT COUNT(*) FROM stockmove",Integer.class)).isEqualTo(1);
var outgoing=movement(svc,u,job,article,3,stock,0,"",0);svc.move(u,job,outgoing);assertThat(balance(db,article,stock)).isEqualTo(2);long move=db.queryForObject("SELECT MAX(stkmv_id) FROM stockmove",Long.class);
svc.move(u,job,movement(svc,u,job,0,0,0,0,"",move));assertThat(balance(db,article,stock)).isEqualTo(5);assertThatThrownBy(()->movement(svc,u,job,0,0,0,0,"",move)).hasMessageContaining("bereits eine Gegenbuchung");
assertThatThrownBy(()->movement(svc,u,job,article,6,stock,0,"",0)).hasMessageContaining("Nicht genügend");assertThatThrownBy(()->movement(svc,u,job,article,30,0,stock,"",0)).hasMessageContaining("Lagerkapazität");
assertThatThrownBy(()->movement(svc,u,job,0,0,0,0,"",99999)).hasMessageContaining("gehört nicht");
// Serial payload survives an exit and its later compensating entry.
svc.move(u,job,movement(svc,u,job,article,1,0,stock,"SER-01",0));db.writeTransaction(()->db.update("UPDATE articleitem SET ati_data_01='Empfänger',ati_data_25='Nachweis' WHERE ati_serialno='SER-01'"));
svc.move(u,job,movement(svc,u,job,article,1,stock,0,"SER-01",0));long serialMove=db.queryForObject("SELECT MAX(stkmv_id) FROM stockmove",Long.class);assertThat(db.queryForObject("SELECT COUNT(*) FROM articleitem",Integer.class)).isZero();svc.move(u,job,movement(svc,u,job,0,0,0,0,"",serialMove));assertThat(db.queryForList("SELECT ati_data_01,ati_data_25 FROM articleitem WHERE ati_serialno='SER-01'").getFirst()).containsEntry("ati_data_01","Empfänger").containsEntry("ati_data_25","Nachweis");
assertThatThrownBy(()->movement(svc,u,job,article,1,0,stock,"SER-01",0)).hasMessageContaining("Seriennummer");
var failed=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("QA rollback")).when(failed).insert(eq("genericdatacontainer"),anyMap());var badSupport=new BusinessSupport(failed,json);var badSvc=new OrderArticles(failed,badSupport,new OrderOperations(failed,badSupport,catalog,pricing),new OrderEntryInventory(failed,catalog),validator);
long count=db.queryForObject("SELECT COUNT(*) FROM stockmove",Long.class);var failMove=movement(svc,u,job,article,1,stock,0,"",0);assertThatThrownBy(()->badSvc.move(u,job,failMove)).hasMessageContaining("QA rollback");assertThat(balance(db,article,stock)).isEqualTo(6);assertThat(db.queryForObject("SELECT COUNT(*) FROM stockmove",Long.class)).isEqualTo(count);
var rows=new ArrayList<>(positions(svc,u,job));rows.add(position(0,station,"Rollback",1));var failSave=articleSave(svc,u,job,rows);assertThatThrownBy(()->badSvc.save(u,job,failSave)).hasMessageContaining("QA rollback");assertThat(positions(svc,u,job)).hasSize(2);
assertThatThrownBy(()->svc.view(new LegacyUser(22,22,8,"foreign","1".repeat(70)),job)).hasMessageContaining("404");assertThatThrownBy(()->svc.move(AppointmentServiceTest.user(4,7),job,failMove)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);
// Legacy bundle corrections cover every component and retain TAN/date/serialized payload.
svc.move(u,job,movement(svc,u,job,article,1,0,stock,"",0));long bundleA=db.queryForObject("SELECT MAX(stkmv_id) FROM stockmove",Long.class);
svc.move(u,job,movement(svc,u,job,article,2,0,stock,"",0));long bundleB=db.queryForObject("SELECT MAX(stkmv_id) FROM stockmove",Long.class);
db.writeTransaction(()->db.update("UPDATE stockmove SET at_id_bundle=987,stkmv_datetime='2026-01-01 12:00:00',stkmv_datetime2='2025-12-31 00:00:00',stkmv_tan='BUNDLE-QA' WHERE stkmv_id IN (?,?)",bundleA,bundleB));
long foreignMove=db.writeTransaction(()->{var row=new LinkedHashMap<>(db.queryForList("SELECT * FROM stockmove WHERE stkmv_id=?",bundleA).getFirst());row.remove("stkmv_id");row.put("jb_id",99999);return db.insert("stockmove",row);});
assertThatThrownBy(()->movement(svc,u,job,0,0,0,0,"",bundleA)).hasMessageContaining("weitere Aufträge");db.writeTransaction(()->db.update("DELETE FROM stockmove WHERE stkmv_id=?",foreignMove));
var bundleCommand=movement(svc,u,job,0,0,0,0,"",bundleA);var bundlePreview=svc.preview(u,job,bundleCommand);assertThat((List<?>)bundlePreview.get("plan")).hasSize(2);
var bundleBefore=db.queryForList("SELECT * FROM stockmove ORDER BY stkmv_id");assertThatThrownBy(()->badSvc.move(u,job,bundleCommand)).hasMessageContaining("QA rollback");assertThat(db.queryForList("SELECT * FROM stockmove ORDER BY stkmv_id")).isEqualTo(bundleBefore);assertThat(balance(db,article,stock)).isEqualTo(9);
svc.move(u,job,bundleCommand);svc.move(u,job,bundleCommand);assertThat(balance(db,article,stock)).isEqualTo(6);
assertThat(db.queryForObject("SELECT COUNT(*) FROM stockmove WHERE at_id_bundle=987",Integer.class)).isEqualTo(4);
assertThatThrownBy(()->movement(svc,u,job,0,0,0,0,"",bundleB)).hasMessageContaining("bereits eine Gegenbuchung");
svc.move(u,job,movement(svc,u,job,article,1,stock,0,"SER-01",0));long legacyMove=db.queryForObject("SELECT MAX(stkmv_id) FROM stockmove",Long.class);
db.writeTransaction(()->{db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngstkmeta' AND gdc_obj_id=?",legacyMove);return db.update("UPDATE stockmove SET stkmv_data='Legacy Empfänger<br><br>Legacy Status',stkmv_tan='LEGACY',stkmv_datetime2='2026-02-01 00:00:00' WHERE stkmv_id=?",legacyMove);});
svc.move(u,job,movement(svc,u,job,0,0,0,0,"",legacyMove));assertThat(db.queryForList("SELECT ati_data_01,ati_data_02,ati_data_03 FROM articleitem WHERE ati_serialno='SER-01'").getFirst()).containsEntry("ati_data_01","Legacy Empfänger").containsEntry("ati_data_02","").containsEntry("ati_data_03","Legacy Status");
var extra=new OrderArticles.Extra("EXTRA-TAN",java.time.LocalDate.of(2026,3,4),List.of("Zusatz A","Zusatz B"));var extraDraft=new OrderArticles.Movement(token(),text(svc.view(u,job),"revision"),0,new ArticleItem(article,1,1,"SER-EXTRA","",new StockBooking(0,stock)),"Zusatzdaten",null,extra);var extraPreview=svc.preview(u,job,extraDraft);
svc.move(u,job,new OrderArticles.Movement(extraDraft.requestId(),extraDraft.revision(),0,extraDraft.item(),extraDraft.reason(),text(extraPreview,"fingerprint"),extra));assertThat(db.queryForList("SELECT ati_data_01,ati_data_02 FROM articleitem WHERE ati_serialno='SER-EXTRA'").getFirst()).containsEntry("ati_data_01","Zusatz A").containsEntry("ati_data_02","Zusatz B");
assertThat(db.queryForList("SELECT stkmv_tan,stkmv_data FROM stockmove ORDER BY stkmv_id DESC LIMIT 1").getFirst()).containsEntry("stkmv_tan","EXTRA-TAN").containsEntry("stkmv_data","Zusatz A<br>Zusatz B");
assertThat(dateTime(db.queryForList("SELECT stkmv_datetime2 FROM stockmove WHERE stkmv_tan='EXTRA-TAN'").getFirst(),"stkmv_datetime2")).isEqualTo(LocalDateTime.of(2026,3,4,0,0));
// Preserve the editable order fixture before changing it into a settled pair.
Map<String,Object> articleFixture=svc.view(u,job),orderFixture=ops.view(u,job);
long[] pair=db.writeTransaction(()->{db.update("UPDATE job SET jb_status=2,jb_id_parent=-1 WHERE jb_id=?",job);var original=new LinkedHashMap<>(db.queryForList("SELECT * FROM job WHERE jb_id=?",job).getFirst());original.remove("jb_id");original.remove("jb_modify");original.putAll(fields("jb_id_parent",job,"jb_fixprice",40,"jb_serviceprice",0,"jb_totalprice",40,"jb_cr_price",40));long pos=db.insert("job",original);original.putAll(fields("jb_id_parent",pos,"jb_fixprice",-40,"jb_totalprice",-40,"jb_cr_price",-40));long neg=db.insert("job",original);for(long child:List.of(pos,neg))for(var t:db.queryForList("SELECT * FROM tour WHERE jb_id=? ORDER BY tr_sort",job)){var copy=new LinkedHashMap<>(t);copy.remove("tr_id");copy.putAll(fields("jb_id",child,"tr_status",1,"tr_signname","Originalempfänger"));db.insert("tour",copy);}return new long[]{pos,neg};});
var recalc=new OrderRecalculation(db,support,ops,catalog,pricing,mock(OrderRoutingService.class),validator);var options=recalc.options(u,job);var previous=(OrderRecalculation.Input)options.get("input");var calcInput=new OrderRecalculation.Input(new Pricing(PriceMode.FIXED,money("150"),money("5"),null,null,money("10"),null,false,1L),1,List.of(),false,false,List.of(new CourierShare(vehicle,money("50"))),previous.childrenRevision());
var draft=new OrderRecalculation.Command(token(),text(options,"revision"),calcInput,null);var preview=recalc.preview(u,job,draft);var command=new OrderRecalculation.Command(draft.requestId(),draft.revision(),draft.input(),text(preview,"fingerprint"));
var toursBefore=db.queryForList("SELECT tr_id,tr_signname,tr_status FROM tour ORDER BY tr_id");recalc.save(u,job,command);recalc.save(u,job,command);assertThat(db.queryForObject("SELECT COUNT(*) FROM job",Integer.class)).isEqualTo(3);assertThat(db.queryForList("SELECT tr_id,tr_signname,tr_status FROM tour ORDER BY tr_id")).isEqualTo(toursBefore);
assertThat(db.queryForObject("SELECT jb_totalprice FROM job WHERE jb_id=?",BigDecimal.class,job)).isEqualByComparingTo("170");assertThat(db.queryForObject("SELECT jb_totalprice FROM job WHERE jb_id=?",BigDecimal.class,pair[0])).isEqualByComparingTo("60");assertThat(db.queryForObject("SELECT jb_totalprice FROM job WHERE jb_id=?",BigDecimal.class,pair[1])).isEqualByComparingTo("-60");assertThat(db.queryForObject("SELECT jbprc_price FROM jobprice WHERE jb_id=? AND mt_sort=2",BigDecimal.class,pair[0])).isEqualByComparingTo("50");
var currentOptions=recalc.options(u,job);var currentInput=(OrderRecalculation.Input)currentOptions.get("input");var check=new OrderRecalculation.Command(token(),text(currentOptions,"revision"),currentInput,null);
db.writeTransaction(()->db.update("UPDATE job SET jb_version=jb_version+1 WHERE jb_id=?",pair[1]));assertThatThrownBy(()->recalc.preview(u,job,check)).hasMessageContaining("zwischenzeitlich");
var freshOptions=recalc.options(u,job);var freshInput=(OrderRecalculation.Input)freshOptions.get("input");var fresh=new OrderRecalculation.Command(token(),text(freshOptions,"revision"),freshInput,null);var newPreview=recalc.preview(u,job,fresh);var newSave=new OrderRecalculation.Command(fresh.requestId(),fresh.revision(),fresh.input(),text(newPreview,"fingerprint"));
var badRecalc=new OrderRecalculation(failed,badSupport,new OrderOperations(failed,badSupport,catalog,pricing),catalog,pricing,mock(OrderRoutingService.class),validator);var beforeJobs=db.queryForList("SELECT * FROM job ORDER BY jb_id");assertThatThrownBy(()->badRecalc.save(u,job,newSave)).hasMessageContaining("QA rollback");assertThat(db.queryForList("SELECT * FROM job ORDER BY jb_id")).isEqualTo(beforeJobs);
var lateSupport=spy(support);doThrow(new org.springframework.dao.DataAccessResourceFailureException("QA second child")).when(lateSupport).audit(eq(u),eq(pair[1]),anyLong(),anyLong(),anyInt(),anyString(),anyString());var lateRecalc=new OrderRecalculation(db,lateSupport,ops,catalog,pricing,mock(OrderRoutingService.class),validator);var beforePrices=db.queryForList("SELECT * FROM jobprice ORDER BY jb_id,mt_sort");assertThatThrownBy(()->lateRecalc.save(u,job,newSave)).hasMessageContaining("QA second child");assertThat(db.queryForList("SELECT * FROM job ORDER BY jb_id")).isEqualTo(beforeJobs);assertThat(db.queryForList("SELECT * FROM jobprice ORDER BY jb_id,mt_sort")).isEqualTo(beforePrices);
db.writeTransaction(()->db.update("INSERT INTO invoice (jb_id,inv_no,inv_datetime) VALUES (?,'QA',NOW())",pair[1]));var invoicedOptions=recalc.options(u,job);assertThatThrownBy(()->recalc.preview(u,job,new OrderRecalculation.Command(token(),text(invoicedOptions,"revision"),(OrderRecalculation.Input)invoicedOptions.get("input"),null))).hasMessageContaining("Rechnung");
assertThatThrownBy(()->recalc.options(u,pair[0])).hasMessageContaining("Hauptauftrag");
db.writeTransaction(()->db.update("DELETE FROM invoice WHERE jb_id=?",pair[1]));
// Remove a pair by compensation; then add a fresh pair without resurrecting the old documents.
var removeOptions=recalc.options(u,job);var removeOld=(OrderRecalculation.Input)removeOptions.get("input");var removeInput=new OrderRecalculation.Input(removeOld.pricing(),1,List.of(),false,false,List.of(),removeOld.childrenRevision(),List.of());
var removeCommand=calculation(recalc,u,job,removeOptions,removeInput);recalc.save(u,job,removeCommand);recalc.save(u,job,removeCommand);
assertThat(db.queryForObject("SELECT jb_id_parent FROM job WHERE jb_id=?",Long.class,job)).isZero();assertThat(db.queryForObject("SELECT COUNT(*) FROM job WHERE jb_storno IN (3,4)",Integer.class)).isEqualTo(2);
assertThat(db.queryForObject("SELECT COUNT(*) FROM job WHERE jb_id_parent=?",Integer.class,job)).isZero();
assertThat(db.queryForObject("SELECT SUM(jb_totalprice) FROM job WHERE jb_id<>?",BigDecimal.class,job)).isEqualByComparingTo("0");
var addOptions=recalc.options(u,job);var addOld=(OrderRecalculation.Input)addOptions.get("input");var addInput=new OrderRecalculation.Input(addOld.pricing(),1,List.of(),false,false,List.of(new CourierShare(vehicle,money("50"))),addOld.childrenRevision(),List.of(0L));var addCommand=calculation(recalc,u,job,addOptions,addInput);recalc.save(u,job,addCommand);recalc.save(u,job,addCommand);
var active=new OrderChildRecalculation(db,ops,support).load(u,ops.snapshot(u,job,false),false);assertThat(active).hasSize(1);long newPos=id(active.getFirst().positive().job(),"jb_id"),newNeg=id(active.getFirst().negative().job(),"jb_id");assertThat(newPos).isNotEqualTo(pair[0]);
assertThat(db.queryForObject("SELECT COUNT(*) FROM job",Integer.class)).isEqualTo(7);assertThat(db.queryForObject("SELECT jb_totalprice FROM job WHERE jb_id=?",BigDecimal.class,newNeg)).isEqualByComparingTo("-60");
// Cancellation preview includes every accounting document and each payment offset.
db.writeTransaction(()->{db.insert("jobpayment",fields("jb_id",job,"csc_id",center,"jbp_price",25,"jbp_counter",1,"jbp_mode",0,"jbp_bookingtime",LocalDateTime.now()));return db.insert("jobpayment",fields("jb_id",newPos,"csc_id",center,"jbp_price",12,"jbp_counter",0,"jbp_mode",1,"jbpc_id",9,"jbp_export_time",LocalDateTime.now(),"jbp_bookingtime",LocalDateTime.now()));});
var feePricing=spy(pricing);doReturn(new LegacyOrderPricing.Tariff(money("10"),money("10"),money("3"),true)).when(feePricing).service(anyLong(),anyLong(),anyString(),eq("Storno"),any(),any());
var feeOps=new OrderOperations(db,support,catalog,feePricing);var cancel=new OrderCancellation(db,support,feeOps,validator);var cancelOptions=cancel.options(u,job);
var missingOffsets=new OrderCancellation.Command(token(),text(cancelOptions,"revision"),new OrderCancellation.Input("CANCEL","QA gemeinsames Storno",true,false,false),null);
assertThatThrownBy(()->cancel.preview(u,job,missingOffsets)).hasMessageContaining("bestätigen");
var cancelDraft=new OrderCancellation.Command(token(),text(cancelOptions,"revision"),new OrderCancellation.Input("CANCEL","QA gemeinsames Storno",true,false,true),null);
assertThatThrownBy(()->cancel.preview(AppointmentServiceTest.user(4,7,38),job,cancelDraft)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);
var cancelPreview=cancel.preview(u,job,cancelDraft);assertThat((List<?>)cancelPreview.get("documents")).hasSize(3);assertThat((BigDecimal)cancelPreview.get("fee")).isEqualByComparingTo("10");
var cancelCommand=new OrderCancellation.Command(cancelDraft.requestId(),cancelDraft.revision(),cancelDraft.input(),text(cancelPreview,"fingerprint"));
var lateCancelSupport=spy(support);doThrow(new org.springframework.dao.DataAccessResourceFailureException("QA cancellation rollback")).when(lateCancelSupport).audit(eq(u),eq(newNeg),anyLong(),anyLong(),eq(10),anyString(),anyString());var badCancel=new OrderCancellation(db,lateCancelSupport,new OrderOperations(db,lateCancelSupport,catalog,feePricing),validator);
var beforeCancel=db.queryForList("SELECT * FROM job ORDER BY jb_id");var beforePayments=db.queryForList("SELECT * FROM jobpayment ORDER BY jbp_id");assertThatThrownBy(()->badCancel.save(u,job,cancelCommand)).hasMessageContaining("QA cancellation rollback");assertThat(db.queryForList("SELECT * FROM job ORDER BY jb_id")).isEqualTo(beforeCancel);assertThat(db.queryForList("SELECT * FROM jobpayment ORDER BY jbp_id")).isEqualTo(beforePayments);
db.writeTransaction(()->db.update("UPDATE job SET jb_export_time=NOW() WHERE jb_id=?",newNeg));var exportedOptions=cancel.options(u,job);assertThat(exportedOptions.get("canCancel")).isEqualTo(false);assertThatThrownBy(()->cancel.preview(u,job,new OrderCancellation.Command(token(),text(exportedOptions,"revision"),cancelDraft.input(),null))).hasMessageContaining("exportiert");
var creditDraft=new OrderCancellation.Command(token(),text(exportedOptions,"revision"),new OrderCancellation.Input("CREDIT","QA gemeinsame Gutschrift",true,false,true),null);var creditPreview=cancel.preview(u,job,creditDraft);var creditCommand=new OrderCancellation.Command(creditDraft.requestId(),creditDraft.revision(),creditDraft.input(),text(creditPreview,"fingerprint"));
long creditId=id(cancel.save(u,job,creditCommand),"resultId");cancel.save(u,job,creditCommand);assertThat(db.queryForObject("SELECT COUNT(*) FROM job",Integer.class)).isEqualTo(10);
assertThat(db.queryForObject("SELECT jb_totalprice FROM job WHERE jb_id=?",BigDecimal.class,creditId)).isEqualByComparingTo("-160");assertThat(db.queryForObject("SELECT jb_cr_price FROM job WHERE jb_id=?",BigDecimal.class,creditId)).isEqualByComparingTo("-60");
assertThat(db.queryForObject("SELECT SUM(trs_price*(1-trs_discount/100)) FROM tourservice WHERE jb_id=?",BigDecimal.class,creditId)).isEqualByComparingTo("-160");assertThat(db.queryForObject("SELECT COUNT(*) FROM job WHERE jb_storno IN (3,4) AND jb_id_parent<>0",Integer.class)).isZero();
assertThat(db.queryForObject("SELECT COUNT(*) FROM job WHERE jb_id_parent<>0",Integer.class)).isZero();
assertThat(db.queryForObject("SELECT SUM(jbp_price) FROM jobpayment",BigDecimal.class)).isEqualByComparingTo("0");assertThat(db.queryForObject("SELECT COUNT(*) FROM jobpayment",Integer.class)).isEqualTo(4);assertThat(db.queryForObject("SELECT COUNT(*) FROM jobpayment WHERE jbpc_id=9",Integer.class)).isEqualTo(1);assertThat(db.queryForObject("SELECT COUNT(*) FROM jobpayment WHERE jbp_counter=1",Integer.class)).isEqualTo(2);
assertThatThrownBy(()->ops.writable(ops.snapshot(u,job,false))).hasMessageContaining("Gegenbuchung");assertThatThrownBy(()->cancel.preview(u,job,new OrderCancellation.Command(token(),text(cancel.options(u,job),"revision"),creditDraft.input(),null))).hasMessageContaining("bereits");
// Cancellation of an offered standing order preserves the assigned courier on both documents.
long offeredJob=db.writeTransaction(()->{var copy=new LinkedHashMap<>(beforeCancel.getFirst());copy.remove("jb_id");copy.remove("jb_modify");copy.putAll(fields("jb_id_copy",0,"jb_id_parent",0,"jb_permanent",1,"jb_status",0,"cr_id",0,"cr_id_order",cr));long created=db.insert("job",copy);db.insert("jobpayment",fields("jb_id",created,"csc_id",center,"jbp_price",5,"jbp_bookingtime",LocalDateTime.now()));return created;});
var offeredOptions=cancel.options(u,offeredJob);var offeredDraft=new OrderCancellation.Command(token(),text(offeredOptions,"revision"),new OrderCancellation.Input("CANCEL","QA angeboten",true,true,true),null);var offeredCommand=new OrderCancellation.Command(offeredDraft.requestId(),offeredDraft.revision(),offeredDraft.input(),text(cancel.preview(u,offeredJob,offeredDraft),"fingerprint"));
long offeredCredit=id(cancel.save(u,offeredJob,offeredCommand),"resultId");cancel.save(u,offeredJob,offeredCommand);
assertThat(db.queryForList("SELECT jb_storno,jb_status,jb_permanent,cr_id FROM job WHERE jb_id=?",offeredJob).getFirst()).containsEntry("jb_storno",1).containsEntry("jb_status",2).containsEntry("jb_permanent",0).containsEntry("cr_id",(int)cr);
assertThat(db.queryForObject("SELECT cr_id FROM job WHERE jb_id=?",Long.class,offeredCredit)).isEqualTo(cr);assertThat(db.queryForObject("SELECT SUM(jbp_price) FROM jobpayment WHERE jb_id=?",BigDecimal.class,offeredJob)).isEqualByComparingTo("0");
assertThat(db.queryForObject("SELECT SUM(trs_price) FROM tourservice WHERE jb_id=?",BigDecimal.class,offeredCredit)).isEqualByComparingTo("-160");
try{json.writeValue(new java.io.File("target/order-remainders-ui-fixture.json"),fields("articles",articleFixture,"order",orderFixture,"calculation",options,"calculationPreview",preview,"articleOption",new ArticleOption(article,"QAA","QA Palette",false,money("2"),1),"stockOption",new StockOption(stock,"QA Lager",6),"cancellation",cancelOptions,"cancellationPreview",cancelPreview));}catch(java.io.IOException e){throw new IllegalStateException(e);}
}
}
@@ -0,0 +1,88 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.JsonNode;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import java.math.BigDecimal;
import java.util.*;
import static de.stadtbote.votian.OrderEntryFixtures.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryService.fields;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class OrderRoutingServiceTest {
final DatabaseService db=mock(DatabaseService.class);
final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);
final OrderIntegrationClient client=mock(OrderIntegrationClient.class);
final OrderRoutingService service=spy(new OrderRoutingService(db,catalog,client,"http://routing.test/api/pzm","","test-key","http://geo.test"));
@BeforeEach void setup() {
when(catalog.parameter(anyLong(),anyString())).thenReturn("");
when(catalog.parameter(0,"LONGHAUL_KM")).thenReturn("100");
doReturn(new OrderRoutingService.Point(50,10)).when(service).geocode(any());
when(client.post(anyString(),any(),anyString())).thenReturn(route(150000));
}
static JsonNode route(int meters) {
return JSON.valueToTree(Map.of("distance",meters,"duration","3600s","zoneDistance",List.of(Map.of("zone","outside_km","distance",meters)),
"extraApproachDistance",List.of(),"extraDepartureCalculation",List.of(),"eapLeg",List.of(),"edcLeg",List.of(),"poliline","_p~iF~ps|U_ulLnnqC_mqNvxq`@"));
}
@Test void ordinaryDomesticFixedPriceDoesNotRequireRoutingWhenLonghaulIsDisabled() {
assertThat(service.resolve(USER,request(),7)).isNull();verifyNoInteractions(client);
}
@Test void automaticDistanceIgnoresManualKmAndHonorsAverageLegThresholdAndAsap() {
var input=with(with(request(),"pricing.mode","KILOMETER"),"distanceKm",1);
var result=service.resolve(USER,input,7);
assertThat(result.info().distanceKm()).isEqualByComparingTo("150.00");assertThat(result.info().longhaul()).isTrue();
assertThat(service.resolve(USER,with(input,"longhaul","ASAP"),7).info().longhaul()).isFalse();
var stations=new ArrayList<>(input.stops());stations.add(input.stops().getLast());
assertThat(service.resolve(USER,with(input,"stops",stations),7).info().longhaulEligible()).isFalse();
verify(client,times(2)).post(anyString(),any(),anyString()); // two stations cached; three stations is a different route
}
@Test void foreignPickupComparesCompleteRoundTripsAndKeepsOwnerSeparate() {
List<Map<String,Object>> branches=List.of(fields("hq_id",7,"hq_mnemonic","A","hq_gps_lat",50,"hq_gps_long",10),
fields("hq_id",8,"hq_mnemonic","B","hq_gps_lat",51,"hq_gps_long",10),fields("hq_id",9,"hq_mnemonic","C","hq_gps_lat",52,"hq_gps_long",10));
when(db.queryForList(startsWith("SELECT hq_id,hq_mnemonic"),any(Object[].class))).thenReturn(branches);
when(client.post(anyString(),any(),anyString())).thenAnswer(call -> {
Map<String,Object> payload=call.getArgument(1);List<String> stations=(List<String>)payload.get("stations");
if(stations.size()==4) {assertThat(stations.getLast()).isEqualTo(stations.getFirst());return route(stations.getFirst().startsWith("51.")?90000:120000);}
return route(60000);
});
var tree=JSON.valueToTree(request());((com.fasterxml.jackson.databind.node.ObjectNode)tree.path("stops").get(0)).put("country","FR");
Request input;try{input=JSON.treeToValue(tree,Request.class);}catch(Exception e){throw new AssertionError(e);}
var result=service.resolve(USER,input,7);
assertThat(result.info().dispatchHeadquartersId()).isEqualTo(8);assertThat(result.info().pricingHeadquartersId()).isEqualTo(7);
assertThat(result.info().comparedHeadquarters()).hasSize(3);assertThat(result.info().comparedHeadquarters().getFirst().roundTripKm()).isEqualByComparingTo("90");
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void longhaulZonesCompareHeadquartersAlongRouteAndUseShortestRouting() {
doReturn(Set.of(8L)).when(service).routeHeadquarters(anyString());
when(client.post(anyString(),any(),anyString())).thenAnswer(call -> {
Map<String,Object> payload=call.getArgument(1);assertThat(payload).containsEntry("shortest",true).containsEntry("roundTripKm",false);
return route(((Number)payload.get("hq_id")).longValue()==8?120000:150000);
});
var result=service.resolve(USER,with(request(),"pricing.mode","ZONE"),7);
assertThat(result.info().pricingHeadquartersId()).isEqualTo(8);assertThat(result.info().dispatchHeadquartersId()).isEqualTo(7);
assertThat(result.info().longhaul()).isTrue();
}
@Test void missingRouteConfigurationDoesNotFallBackToAirDistanceOrUserKm() {
var unconfigured=new OrderRoutingService(db,catalog,client,"","","","http://geo.test");
assertThatThrownBy(() -> unconfigured.route(List.of(new OrderRoutingService.Point(50,10),new OrderRoutingService.Point(51,11)),7,true,false)).hasMessageContaining("PZM_URL");
verifyNoInteractions(client);
}
@Test void invalidMetricsAndBrokenPolylinesFailClearly() {
assertThatThrownBy(() -> OrderRoutingService.distance(JSON.createObjectNode())).hasMessageContaining("Entfernung");
assertThatThrownBy(() -> OrderRoutingService.decodePolyline("_")).hasMessageContaining("Routenverlauf");
assertThat(OrderRoutingService.decodePolyline("_p~iF~ps|U_ulLnnqC_mqNvxq`@")).containsExactly(
new OrderRoutingService.Point(38.5,-120.2),new OrderRoutingService.Point(40.7,-120.95),new OrderRoutingService.Point(43.252,-126.453));
assertThat(OrderRoutingService.contains("0 0,2 0,2 2,0 2",new OrderRoutingService.Point(1,1))).isTrue();
assertThat(OrderRoutingService.contains("0 0,2 0,2 2,0 2",new OrderRoutingService.Point(3,1))).isFalse();
}
@Test void partialGeocodingNeverBecomesAnAcceptedStation() throws Exception {
var real=new OrderRoutingService(db,catalog,client,"http://routing.test","","test-key","http://geo.test");
when(client.get(anyString(),anyString())).thenReturn(JSON.readTree("""
{"status":"OK","results":[{"partial_match":true,"geometry":{"location_type":"ROOFTOP","location":{"lat":50,"lng":10}}}]}
"""));
assertThatThrownBy(() -> real.geocode(request().stops().getFirst())).hasMessageContaining("ungenau");
}
}
@@ -0,0 +1,51 @@
package de.stadtbote.votian;
import org.junit.jupiter.api.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryFixtures.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryService.*;
class OrderSustainabilityTest {
final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);
final OrderRoutingService routing=mock(OrderRoutingService.class);
final OrderIntegrationClient client=mock(OrderIntegrationClient.class);
final OrderSustainability service=new OrderSustainability(catalog,routing,client,"https://co2.example.test/evaluate","test-user","test-password");
final Map<String,Object> vehicle=fields("vht_id",2,"crvh_env_badge_level",2,"crvh_payload",1000);
Prepared prepared(){var time=LocalDateTime.parse("2026-10-12T10:00:00");return new Prepared(Map.of(),Map.of(),vehicle,time,time,0,7,false,31);}
Request input(){return with(with(with(request(),"mode","RETROSPECTIVE"),"distanceKm",20),"sustainability",new SustainabilityInput(SustainabilityMode.AUTO,null,null));}
@BeforeEach void setup(){when(catalog.parameter(anyLong(),anyString())).thenReturn("");}
@Test void followsPhpContractUsesHalfPayloadAndCachesQuoteToSave() {
when(client.postAuthenticated(anyString(),any(),anyString(),anyString(),anyString())).thenAnswer(c -> {
var row=JSON.valueToTree(c.getArgument(1)).path("data").get(0);
assertThat(row.path("gewicht").decimalValue()).isEqualByComparingTo("500");assertThat(row.path("mlp").decimalValue()).isEqualByComparingTo("0.1255");assertThat(row.path("gefahreneKm").intValue()).isEqualTo(20);
return JSON.valueToTree(Map.of("data",List.of(Map.of("referenz",row.path("referenz").longValue(),"co2","1.235"))));
});
var value=service.resolve(USER,input(),prepared(),null);
assertThat(value.co2eKg()).isEqualByComparingTo("1.24");assertThat(value.calculation()).contains("T=500","MLP=0.1255","TF=1000");
assertThat(service.resolve(USER,input(),prepared(),null)).isEqualTo(value);
verify(client,times(1)).postAuthenticated(anyString(),any(),anyString(),eq("test-user"),eq("test-password"));verifyNoInteractions(routing);
var enriched=service.apply(USER,input(),prepared(),quote(),value);assertThat(enriched.sustainability()).isEqualTo(value);assertThat(enriched.fingerprint()).isNotEqualTo(quote().fingerprint());
vehicle.put("crvh_payload",2000);assertThatThrownBy(() -> service.apply(USER,input(),prepared(),quote(),value)).hasMessageContaining("geändert");
}
@Test void rejectsForeignMissingInvalidAndErrorResponses() {
for(var response:List.of("{}","{\"errors\":[\"failure\"]}","{\"data\":[{\"referenz\":0,\"co2\":1}]}","{\"data\":[{\"co2\":\"NaN\"}]}")) {
try {when(client.postAuthenticated(anyString(),any(),anyString(),anyString(),anyString())).thenReturn(JSON.readTree(response));}catch(Exception e){throw new AssertionError(e);}
assertThatThrownBy(() -> service.resolve(USER,input(),prepared(),null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
}
}
@Test void manualModeNeedsBothValuesAndMakesNoExternalCall() {
var input=with(input(),"sustainability",new SustainabilityInput(SustainabilityMode.MANUAL,new BigDecimal("12.5"),new BigDecimal("2.3")));
assertThat(service.resolve(USER,input,prepared(),null).source()).isEqualTo("Manuell");verifyNoInteractions(client,routing);
assertThatThrownBy(() -> service.resolve(USER,with(input,"mode","IMMEDIATE"),prepared(),null)).hasMessageContaining("Nacherfassungen");
assertThatThrownBy(() -> service.resolve(USER,with(input,"sustainability.distanceKm",null),prepared(),null)).hasMessageContaining("Kilometer");
assertThatThrownBy(() -> service.resolve(USER,with(input,"sustainability.mode","AUTO"),prepared(),null)).hasMessageContaining("kombiniert");
}
@Test void failsClearlyWhenCredentialsAreMissing() {
var missing=new OrderSustainability(catalog,routing,client,"","","");
assertThatThrownBy(() -> missing.resolve(USER,input(),prepared(),null)).hasMessageContaining("CO2_URL");verifyNoInteractions(client);
}
}
@@ -0,0 +1,142 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.security.access.AccessDeniedException;
import java.time.*;
import java.util.*;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static de.stadtbote.votian.OrderEntryService.fields;
import static de.stadtbote.votian.ReportAdministration.*;
class ReportAdministrationTest {
final DatabaseService db=mock(DatabaseService.class);
final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);
final ReportAdministration service=new ReportAdministration(db,catalog);
final LegacyUser master=user(0,1,3,10,16), ordinary=user(0,1,16);
final List<String> queries=new ArrayList<>();
Map<String,Object> report=fields("rp_id",44L,"hq_id",7L,"rp_objtype","cs","rp_objid",9L,"usr_id",11L,"rp_reporttype",1,"rp_text","Alt","rp_confidential",0,"rp_createtime",LocalDateTime.of(2026,10,12,9,0));
boolean missingObject=false,missingReport=false;
static LegacyUser user(int... rights) { char[] bits="0".repeat(70).toCharArray();for(int r:rights)bits[r]='1';return new LegacyUser(11,12,7,"test",new String(bits)); }
Query query() { return new Query(LocalDate.of(2026,7,1),LocalDate.of(2026,10,12),List.of(7L),null,null,"",0,Sort.DATE,true,false,false); }
Save save() { return new Save(AdminSupport.revision(version(report)),9,2,"Neuer Bericht",true); }
@BeforeEach void setup() {
when(db.readTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.writeTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{
String sql=c.getArgument(0);queries.add(sql);
if(sql.startsWith("SELECT mt_sort"))return List.of(fields("id",1,"label","Verschiedenes"),fields("id",2,"label","Abschluss"));
if(sql.startsWith("SELECT DISTINCT h.hq_id"))return sql.contains(" AND h.hq_id=?")?List.of(fields("id",7L,"label","B")):List.of(fields("id",7L,"label","B"),fields("id",8L,"label","HH"));
if(sql.startsWith("SELECT cs_id FROM")||sql.startsWith("SELECT cr_id FROM"))return missingObject?List.of():List.of(fields("id",9L));
if(sql.startsWith("SELECT COUNT(*)"))return List.of(fields("total",1));
if(sql.startsWith("SELECT r.hq_id,r.usr_id"))return List.of(fields("hq_id",7,"usr_id",11,"author","Test","headquarters","B","total",1,"rp_reporttype",2,"cmp_type",1));
if(sql.startsWith("SELECT r.rp_id"))return missingReport?List.of():List.of(report);
return List.of();
});
when(db.insert(anyString(),anyMap())).thenReturn(45L);
when(db.update(anyString(),any(Object[].class))).thenReturn(1);
}
@Test void requiresObjectAndReportRightsBeforeAnyDatabaseAccess() {
for(var who:List.of(user(0,1),user(16)))for(var kind:Kind.values()) {
assertThatThrownBy(()->service.options(who,kind)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->service.create(who,kind,save())).isInstanceOf(AccessDeniedException.class);
}
verifyNoInteractions(db,catalog);
}
@Test void confidentialReportsAreFilteredInListCountAndStatistics() {
service.list(ordinary,Kind.CUSTOMER,query());service.statistics(ordinary,Kind.CUSTOMER,query());
var selections=queries.stream().filter(sql->sql.contains("FROM "+TABLE)).toList();
assertThat(selections).hasSize(3).allMatch(sql->sql.contains("(r.rp_confidential=0 OR r.usr_id=?)"));
queries.clear();service.list(master,Kind.CUSTOMER,query());
assertThat(queries).noneMatch(sql->sql.contains("r.rp_confidential=0"));
}
@Test void crossHeadquartersRequiresMasterAndSameMandator() {
var q=new Query(query().from(),query().to(),List.of(8L),null,null,"",0,Sort.DATE,true,false,false);
assertThatThrownBy(()->service.list(ordinary,Kind.CUSTOMER,q)).isInstanceOf(AccessDeniedException.class);
var foreign=new Query(q.from(),q.to(),List.of(999L),null,null,"",0,Sort.DATE,true,false,false);
assertThatThrownBy(()->service.list(master,Kind.CUSTOMER,foreign)).isInstanceOf(AccessDeniedException.class);
assertThat(queries).noneMatch(sql->sql.contains("FROM "+TABLE));
service.list(master,Kind.CUSTOMER,q);
}
@Test void emptyHeadquartersAndInvalidIntervalsNeverBroadenTheQuery() {
assertThatThrownBy(()->service.list(master,Kind.CUSTOMER,new Query(query().from(),query().to(),List.of(),null,null,"",0,Sort.DATE,true,false,false))).hasMessageContaining("Niederlassung");
assertThatThrownBy(()->service.list(master,Kind.CUSTOMER,new Query(query().to(),query().from(),List.of(7L),null,null,"",0,Sort.DATE,true,false,false))).hasMessageContaining("Zeitraum");
assertThat(queries).noneMatch(sql->sql.contains("FROM "+TABLE));
}
@Test void parentCustomerAndBoundTextFilterAlsoApplyToStatistics() {
String search="x' OR 1=1";
var q=new Query(query().from(),query().to(),List.of(7L),9L,2,search,3,Sort.AUTHOR,false,true,true);
service.list(master,Kind.CUSTOMER,q);var stats=service.statistics(master,Kind.CUSTOMER,q);
assertThat(queries.stream().filter(s->s.contains("FROM "+TABLE))).allMatch(sql->sql.contains("(r.rp_objid=? OR o.cs_id_parent=?)")&&sql.contains("r.rp_reporttype=?")&&sql.contains("r.rp_text LIKE ?")&&!sql.contains(search));
assertThat(queries).anyMatch(sql->sql.contains("u.usr_name ASC,u.usr_firstname ASC")&&sql.endsWith("OFFSET ?"));
assertThat(stats.get("total")).isEqualTo(1L);
assertThat(((List<?>)stats.get("rows")).getFirst()).isEqualTo(fields("headquarters","B","author","Test","type","Abschluss","customerType","Verschiedenes","total",1L));
}
@Test void courierReportsUseTheirOwnMetadataAndNoCustomerParentLogic() {
var q=new Query(query().from(),query().to(),List.of(7L),9L,null,"",0,Sort.TYPE,true,false,false);
service.list(master,Kind.COURIER,q);
verify(db).queryForList(contains("mt_type=?"),eq("report_type_2"));
assertThat(queries).noneMatch(sql->sql.contains("cs_id_parent"));
assertThatThrownBy(()->service.statistics(master,Kind.COURIER,new Query(q.from(),q.to(),q.headquarters(),null,null,"",0,Sort.DATE,true,false,true))).hasMessageContaining("Kundentyp");
}
@Test void createUsesSessionAuthorHeadquartersAndLiteralText() {
var result=service.create(ordinary,Kind.CUSTOMER,new Save(null,9,2,"<script>kein HTML</script>",true));
assertThat(result).containsEntry("id",45L).containsEntry("warning","");
verify(db).insert(eq(TABLE),argThat(v->v.get("hq_id").equals(7L)&&v.get("usr_id").equals(11L)&&v.get("rp_objtype").equals("cs")&&v.get("rp_text").equals("<script>kein HTML</script>")&&v.get("rp_confidential").equals(1)));
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void invalidObjectsTypesAndUtf8LengthCannotCreateReports() {
missingObject=true;
assertThatThrownBy(()->service.create(master,Kind.CUSTOMER,save())).hasMessageContaining("404");missingObject=false;
for(var input:List.of(new Save(null,9,99,"x",false),new Save(null,9,128,"x",false),new Save(null,9,2," ",false),new Save(null,9,2,"€".repeat(22_000),false)))assertThatThrownBy(()->service.create(master,Kind.CUSTOMER,input)).hasMessageContaining("400");
verify(db,never()).insert(anyString(),anyMap());
}
@Test void onlyOwnerOrEmployeeAdministratorMayEditButOnlyOwnerMayDelete() {
report.put("usr_id",88L);
assertThatThrownBy(()->service.save(ordinary,Kind.CUSTOMER,44,save())).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->service.remove(master,Kind.CUSTOMER,44,save().revision())).isInstanceOf(AccessDeniedException.class);
verify(db,never()).update(anyString(),any(Object[].class));
service.save(master,Kind.CUSTOMER,44,save());
verify(db).update(startsWith("UPDATE "+TABLE),any(Object[].class));
}
@Test void unchangedRevisionAndImmutableObjectAreRequired() {
assertThatThrownBy(()->service.save(master,Kind.CUSTOMER,44,new Save("old",9,2,"text",false))).hasMessageContaining("409");
assertThatThrownBy(()->service.save(master,Kind.CUSTOMER,44,new Save(save().revision(),99,2,"text",false))).hasMessageContaining("Zuordnung");
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void concurrentLegacyWriteAfterRevisionCheckStillFailsCompareAndSet() {
when(db.update(anyString(),any(Object[].class))).thenReturn(0);
assertThatThrownBy(()->service.save(master,Kind.CUSTOMER,44,save())).hasMessageContaining("409");
verify(db).update(contains("AND BINARY rp_text <=> BINARY ? AND rp_confidential=? AND rp_createtime <=> ?"),any(Object[].class));
assertThatThrownBy(()->service.remove(master,Kind.CUSTOMER,44,save().revision())).hasMessageContaining("409");
verify(db,never()).lockRows(anyString(),any(Object[].class));
}
@Test void inaccessibleReportCannotBeMutated() {
missingReport=true;
assertThatThrownBy(()->service.save(ordinary,Kind.CUSTOMER,44,save())).hasMessageContaining("404");
assertThat(queries).anyMatch(sql->sql.contains("r.rp_id=? AND r.rp_objtype=? AND r.hq_id IN (?)")&&sql.contains("r.usr_id=?"));
verify(db,never()).update(anyString(),any(Object[].class));
}
@Test void optionalAppointmentFailureDoesNotMisreportSavedMyIsamReport() {
when(catalog.enabled(0,"MASK_RP_ENABLE_DEACTIVATION_APPOINTMENT_WARNING_OF_SAME_DAY")).thenReturn(true);
when(db.update(startsWith("UPDATE `phoenix_group`.appointment"),any(Object[].class))).thenThrow(new DataAccessResourceFailureException("offline"));
var result=service.create(ordinary,Kind.CUSTOMER,save());
assertThat(result).containsEntry("id",45L);assertThat(result.get("warning").toString()).contains("gespeichert");
verify(db,times(1)).insert(eq(TABLE),anyMap());
verify(db,times(2)).writeTransaction(any());
}
@Test void retryIsBoundToReportCustomerHeadquartersAndOriginalDay() {
when(catalog.enabled(0,"MASK_RP_ENABLE_DEACTIVATION_APPOINTMENT_WARNING_OF_SAME_DAY")).thenReturn(true);
service.retryAppointments(ordinary,Kind.CUSTOMER,44);
verify(db).update(contains("WHERE cs_id=? AND hq_id=? AND ap_execdate>=? AND ap_execdate<? AND ap_category_4=1"),eq(9L),eq(7L),eq(LocalDateTime.of(2026,10,12,0,0)),eq(LocalDateTime.of(2026,10,13,0,0)));
}
@Test void courierCreateNeverUpdatesCustomerAppointments() {
when(catalog.enabled(anyLong(),anyString())).thenReturn(true);
service.create(ordinary,Kind.COURIER,save());
verify(db,never()).update(anyString(),any(Object[].class));
}
}
@@ -0,0 +1,54 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.LegacyMapping.*;
public class ReportFormsIntegrationAssertions {
public static List<String> setup(){var setup=new ArrayList<String>();
for(String table:List.of("metafieldvalue","metafieldvaluehistory","metafieldcategorykey","metafieldkey","metafieldcategory","metafieldtemplate","parameter","headquarters","mandatorheadquarters","company","address","customer","courier","job","costcenter","couriervehicle"))setup.add("DROP TABLE IF EXISTS "+table);
setup.add("CREATE DATABASE IF NOT EXISTS phoenix_group");setup.add("DROP TABLE IF EXISTS phoenix_group.report_process");
setup.add("CREATE TABLE phoenix_group.report_process (rp_id INT PRIMARY KEY,hq_id INT,rp_objtype VARCHAR(2),rp_objid INT,usr_id INT,rp_reporttype INT,rp_text TEXT,rp_confidential INT,rp_createtime DATETIME) ENGINE=MyISAM");
setup.add("INSERT INTO phoenix_group.report_process VALUES(44,7,'cs',9,11,1,'Besuch',0,'2026-10-13 09:00:00'),(45,7,'cs',9,12,1,'Vertraulich',1,'2026-10-13 09:00:00'),(46,7,'cr',9,11,1,'Fahrerbesuch',0,'2026-10-13 09:00:00')");
setup.add("CREATE TABLE headquarters(hq_id INT PRIMARY KEY,hq_name VARCHAR(60),hq_mnemonic VARCHAR(20),cmp_id INT) ENGINE=InnoDB");setup.add("INSERT INTO headquarters VALUES(7,'Berlin Test','B',1),(8,'Hamburg Test','HH',1),(9,'Fremd','F',1)");
setup.add("CREATE TABLE mandatorheadquarters(md_id INT,hq_id INT) ENGINE=InnoDB");setup.add("INSERT INTO mandatorheadquarters VALUES(1,7),(1,8),(2,9)");
setup.add("CREATE TABLE company(cmp_id INT PRIMARY KEY,cmp_comp VARCHAR(100),cmp_comp2 VARCHAR(100),cmp_hsno VARCHAR(10),ad_id INT,cmp_logo VARCHAR(200),cmp_logo_width INT,cmp_logo_height INT) ENGINE=InnoDB");setup.add("INSERT INTO company VALUES(1,'Musterkunde','','7',1,'',0,0),(2,'Transporteur','','9',1,'',0,0)");
setup.add("CREATE TABLE address(ad_id INT PRIMARY KEY,ad_street VARCHAR(100),ad_zipcode VARCHAR(10),ad_city VARCHAR(100)) ENGINE=InnoDB");setup.add("INSERT INTO address VALUES(1,'Teststraße','10115','Berlin')");
setup.add("CREATE TABLE customer(cs_id INT PRIMARY KEY,cs_eid VARCHAR(50),cmp_id INT,hq_id INT) ENGINE=InnoDB");setup.add("INSERT INTO customer VALUES(9,'TEST-001',1,7)");
setup.add("CREATE TABLE courier(cr_id INT PRIMARY KEY,cr_eid VARCHAR(50),cmp_id INT,hq_id INT) ENGINE=InnoDB");setup.add("INSERT INTO courier VALUES(9,'TEST-HT',2,7)");
setup.add("CREATE TABLE couriervehicle(crvh_id INT PRIMARY KEY,cr_id INT,crvh_sid VARCHAR(20),crvh_vh_sign VARCHAR(20)) ENGINE=InnoDB");setup.add("INSERT INTO couriervehicle VALUES(1,9,'HT-01','B-QA-1')");
setup.add("CREATE TABLE parameter(par_key VARCHAR(100),par_value VARCHAR(200),hq_id INT,emp_id INT) ENGINE=InnoDB");
setup.add("CREATE TABLE metafieldcategory(mtfc_id INT PRIMARY KEY,mtfc_description VARCHAR(100),mtft_id INT) ENGINE=InnoDB");setup.add("INSERT INTO metafieldcategory VALUES(101,'Aktivitätsbericht Verkauf',1),(102,'Vorortprüfung',2)");
setup.add("CREATE TABLE metafieldtemplate(mtft_id INT PRIMARY KEY,mtft_content TEXT) ENGINE=InnoDB");setup.add("INSERT INTO metafieldtemplate VALUES(1,'<h2>Aktivitätsbericht Verkauf</h2><p>Firma: [-1-]</p><p>Neukunde: [-3-]</p><p>Gesprächspartner: [-4-]</p><p>Gesprächsinhalt: [-2-]</p><script>bad()</script>'),(2,'<h2>Vorortprüfung</h2><table><tr><td>Pünktlichkeit</td><td>[-3-]</td></tr></table>')");
setup.add("CREATE TABLE metafieldkey(mtfk_id INT PRIMARY KEY,mtfk_name VARCHAR(100),mtfk_type VARCHAR(100)) ENGINE=InnoDB");setup.add("INSERT INTO metafieldkey VALUES(1,'Firma','db:cs:company_name'),(2,'Gesprächsinhalt','text'),(3,'Neukunde','checkbox'),(4,'Gesprächspartner','varchar'),(5,'Zeit','time')");
setup.add("CREATE TABLE metafieldcategorykey(mtfck_id INT PRIMARY KEY,mtfk_id INT,mtfc_id INT,hq_id INT,mtfck_sort INT,mtfck_history INT) ENGINE=InnoDB");setup.add("INSERT INTO metafieldcategorykey VALUES(11,1,101,0,1,0),(12,2,101,0,2,1),(13,3,101,0,3,1),(14,4,101,0,4,0),(15,5,101,0,5,0),(21,3,102,0,1,1)");
setup.add("CREATE TABLE metafieldvalue(mtfck_id INT,mtfv_id INT,mtfv_value TEXT) ENGINE=InnoDB");setup.add("CREATE TABLE metafieldvaluehistory(mtfck_id INT,mtfv_id INT,mtfv_value TEXT,mtfvh_createtime DATETIME) ENGINE=InnoDB");
setup.add("INSERT INTO metafieldvalue VALUES(999,44,'Andere Kategorie'),(12,45,'Geheim')");
return setup;}
public static void verify(DatabaseService db) throws Exception {
var user=new LegacyUser(11,12,7,"QA","1".repeat(70));var reports=new ReportAdministration(db,mock(OrderEntryCatalog.class));var forms=new ReportForms(db,reports,new FormAdministration(db),new NewsletterTemplate("https://test.sb.assecutor.de",new NewsletterImages("target/report-form-images")));
var initial=forms.get(user,ReportAdministration.Kind.CUSTOMER,44,101);
assertThat(text(initial,"html")).contains("Musterkunde").doesNotContain("script");assertThat(text(initial,"layout")).contains("NGFORMFIELD12END");
var command=new ReportForms.Save(text(initial,"revision"),text(initial,"schemaRevision"),text(initial,"reportRevision"),Map.of(12L,"Wöchentlich zwei Abholungen.",13L,"1",14L,"Mara Muster",15L,"09:15"));
forms.save(user,ReportAdministration.Kind.CUSTOMER,44,101,command);forms.save(user,ReportAdministration.Kind.CUSTOMER,44,101,command);
assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvaluehistory",Integer.class)).isEqualTo(2);assertThat(db.queryForObject("SELECT mtfv_value FROM metafieldvalue WHERE mtfck_id=999",String.class)).isEqualTo("Andere Kategorie");
var saved=forms.get(user,ReportAdministration.Kind.CUSTOMER,44,101);assertThat(text(saved,"html")).contains("Mara Muster","Wöchentlich zwei Abholungen.","✓");
var json=new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();json.writeValue(java.nio.file.Path.of("target/report-form-ui-fixture.json").toFile(),Map.of("101",saved,"102",forms.get(user,ReportAdministration.Kind.CUSTOMER,44,102)));
assertThatThrownBy(()->forms.save(user,ReportAdministration.Kind.CUSTOMER,44,101,new ReportForms.Save(text(initial,"revision"),text(initial,"schemaRevision"),text(initial,"reportRevision"),Map.of(12L,"Überschreiben")))).hasMessageContaining("409");
assertThatThrownBy(()->forms.save(user,ReportAdministration.Kind.CUSTOMER,44,101,new ReportForms.Save(text(saved,"revision"),text(saved,"schemaRevision"),text(saved,"reportRevision"),Map.of(11L,"Falsche Firma")))).hasMessageContaining("Automatische");
assertThatThrownBy(()->forms.save(user,ReportAdministration.Kind.CUSTOMER,44,101,new ReportForms.Save(text(saved,"revision"),text(saved,"schemaRevision"),text(saved,"reportRevision"),Map.of(15L,"25:00")))).hasMessageContaining("HH:MM");
assertThat(text(forms.get(user,ReportAdministration.Kind.COURIER,46,101),"html")).doesNotContain("Musterkunde");
var limited=new LegacyUser(13,14,7,"QA", "1".repeat(3)+"0"+"1".repeat(6)+"0"+"1".repeat(59));
assertThat(forms.get(limited,ReportAdministration.Kind.CUSTOMER,44,101)).containsEntry("canEdit",false);
assertThatThrownBy(()->forms.get(limited,ReportAdministration.Kind.CUSTOMER,45,101)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
assertThatThrownBy(()->forms.save(limited,ReportAdministration.Kind.CUSTOMER,44,101,command)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);
var failing=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("simulated history failure")).when(failing).update(startsWith("INSERT INTO metafieldvaluehistory"),any(Object[].class));
var failForms=new ReportForms(failing,reports,new FormAdministration(failing),new NewsletterTemplate("",new NewsletterImages("target/report-form-images")));
assertThatThrownBy(()->failForms.save(user,ReportAdministration.Kind.CUSTOMER,44,101,new ReportForms.Save(text(saved,"revision"),text(saved,"schemaRevision"),text(saved,"reportRevision"),Map.of(12L,"Fehlschlag")))).hasMessageContaining("simulated");
assertThat(db.queryForObject("SELECT mtfv_value FROM metafieldvalue WHERE mtfck_id=12 AND mtfv_id=44",String.class)).isEqualTo("Wöchentlich zwei Abholungen.");
db.writeTransaction(()->db.update("UPDATE phoenix_group.report_process SET rp_text='Zwischenzeitlich geändert' WHERE rp_id=44"));
assertThatThrownBy(()->forms.save(user,ReportAdministration.Kind.CUSTOMER,44,101,command)).hasMessageContaining("409");
}
}
@@ -0,0 +1,21 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.Test;
import java.time.*;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryService.fields;
import static de.stadtbote.votian.ServicePriceAdministration.*;
class ServicePriceAdministrationTest {
final DatabaseService db=mock(DatabaseService.class);final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);final ServicePriceAdministration service=new ServicePriceAdministration(db,catalog,new ObjectMapper().findAndRegisterModules());
@Test void checksRightsBeforeReadingOrWriting(){var u=AppointmentServiceTest.user(0);assertThatThrownBy(()->service.options(u)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.grid(u,null)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.save(u,null)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);verifyNoInteractions(db,catalog);}
@Test void generalAndCustomerTariffsHaveSeparateRights(){var u=AppointmentServiceTest.user(2);assertThatThrownBy(()->service.scope(u,new Query(0,0,service.today()))).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.scope(u,new Query(0,9,service.today()))).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);verifyNoInteractions(db,catalog);}
@Test void rejectsInvalidDatesAndMode(){var u=AppointmentServiceTest.user(2,29);for(var q:List.of(new Query(0,0,service.today().minusDays(1)),new Query(1,0,service.today().plusDays(1)),new Query(2,0,service.today()),new Query(0,0,service.today().plusYears(2))))assertThatThrownBy(()->service.scope(u,q)).hasMessageContaining("400");verifyNoInteractions(db);}
@Test void validatesDatabasePrecisionAndKeepsNegativeSentinels(){assertThat(input("12,34",false)).isEqualByComparingTo("12.34");assertThat(input("",true)).isZero();assertThat(input("-0.01",false)).isEqualByComparingTo("-0.01");for(String s:List.of("NaN","1e2","1.234","100000","--1"))assertThatThrownBy(()->input(s,false)).hasMessageContaining("400");assertThatThrownBy(()->input("1000",true)).hasMessageContaining("Wertebereich");}
@Test void restrictedColumnsAreExcludedFromModeZero(){when(catalog.parameter(anyLong(),eq("RIGHTS_TABLE_SERVICE"))).thenReturn("Geheim=50|Normal=2");when(db.queryForList(startsWith("SELECT d.srv_id"),any(Object[].class))).thenReturn(List.of(fields("id",1,"name","Geheim"),fields("id",2,"name","Normal")));assertThat(service.definitions(AppointmentServiceTest.user(2,29),new Query(0,0,service.today()),7,false,false)).extracting(r->r.get("name")).containsExactly("Normal");}
@Test void duplicateCellsAndMalformedValuesAreRejectedBeforeTransaction(){var u=AppointmentServiceTest.user(2,29);var q=new Query(0,0,service.today());var c=new Cell(1,1,"1","1");assertThatThrownBy(()->service.save(u,new Save(UUID.randomUUID().toString(),q,"r",false,List.of(c,c)))).hasMessageContaining("doppelt");verify(db,never()).writeTransaction(any());}
@Test void definitionDeleteRequiresOwnScopeAndAdditionalRight(){var defs=new ServiceDefinitionAdministration(db,service,new ObjectMapper(),"crm_qa");assertThatThrownBy(()->defs.change(AppointmentServiceTest.user(2,29),new ServiceDefinitionAdministration.Change(UUID.randomUUID().toString(),new Query(1,0,service.today()),ServiceDefinitionAdministration.Kind.PRICE,1,"r","",true,null))).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);verify(db,never()).writeTransaction(any());}
}
@@ -0,0 +1,46 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseService;
import java.time.*;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.ServicePriceAdministration.*;
import static de.stadtbote.votian.ServiceDefinitionAdministration.*;
import static de.stadtbote.votian.LegacyMapping.*;
public class ServicePriceIntegrationAssertions {
static class TimedPrices extends ServicePriceAdministration {
LocalDateTime at=LocalDate.now(ZoneId.of("Europe/Berlin")).atTime(12,0);
TimedPrices(DatabaseService db){super(db,new OrderEntryCatalog(db),new ObjectMapper().findAndRegisterModules());}
@Override LocalDateTime now(){return at;}
}
static String token(){return UUID.randomUUID().toString();}
static Save save(TimedPrices p,LegacyUser u,Query q,boolean discount,Cell cell){return new Save(token(),q,(String)p.grid(u,q).get("revision"),discount,List.of(cell));}
@SuppressWarnings("unchecked") public static void verify(DatabaseService db){
var p=new TimedPrices(db);var u=AppointmentServiceTest.user(0,2,8,10,25,29,31);var q=new Query(0,0,p.today());
var first=save(p,u,q,false,new Cell(1,10,"25,50","18"));assertThat(p.save(u,first)).containsEntry("changed",1);assertThat(p.save(u,first)).containsEntry("repeated",true);
var row=db.queryForList("SELECT * FROM servicecustomer WHERE srv_id=1 AND srvt_id=10 AND hq_id=7 AND cs_id=0").getFirst();assertThat(amount(row.get("srvc_price"))).isEqualByComparingTo("25.50");assertThat(amount(row.get("srvc_discount"))).isEqualByComparingTo("2");assertThat(amount(row.get("srvc_cr_discount"))).isEqualByComparingTo("3");
assertThat(db.queryForObject("SELECT COUNT(*) FROM servicehistory WHERE srv_id=1",Integer.class)).isEqualTo(2);
assertThatThrownBy(()->p.save(u,new Save(token(),q,first.revision(),false,List.of(new Cell(1,10,"30","20"))))).hasMessageContaining("409");
assertThatThrownBy(()->p.save(u,save(p,u,q,true,new Cell(1,10,"5","4")))).hasMessageContaining("gerade gespeichert");
p.at=p.at.plusSeconds(2);p.save(u,save(p,u,q,true,new Cell(1,10,"5","4")));row=db.queryForList("SELECT * FROM servicehistory WHERE srv_id=1 ORDER BY srvh_validfrom DESC LIMIT 1").getFirst();assertThat(amount(row.get("srvh_price"))).isEqualByComparingTo("25.50");assertThat(amount(row.get("srvh_discount"))).isEqualByComparingTo("5");
var future=new Query(0,0,p.today().plusDays(3));p.save(u,save(p,u,future,false,new Cell(1,10,"40","28")));p.save(u,save(p,u,future,true,new Cell(1,10,"7","6")));
assertThat(db.queryForObject("SELECT COUNT(*) FROM servicehistory WHERE srvh_validfrom=?",Integer.class,future.date().atStartOfDay())).isEqualTo(1);row=db.queryForList("SELECT * FROM servicehistory WHERE srvh_validfrom=?",future.date().atStartOfDay()).getFirst();assertThat(amount(row.get("srvh_price"))).isEqualByComparingTo("40");assertThat(amount(row.get("srvh_discount"))).isEqualByComparingTo("7");
assertThat(amount(db.queryForList("SELECT srvc_price FROM servicecustomer WHERE srv_id=1").getFirst().get("srvc_price"))).isEqualByComparingTo("25.50");
var previousTime=p.at;p.at=future.date().atTime(12,0);var effective=((List<Map<String,Object>>)p.grid(u,new Query(0,0,p.today())).get("cells")).getFirst();assertThat(effective).containsEntry("price","40.00").containsEntry("discount","7.00");p.at=previousTime;
var customer=new Query(1,9,p.today());assertThat((List<Map<String,Object>>)p.grid(u,customer).get("columns")).extracting(r->id(r,"id")).containsExactly(2L,3L);assertThatThrownBy(()->p.grid(u,new Query(1,99,p.today()))).hasMessageContaining("404");
var forbidden=save(p,u,customer,false,new Cell(4,11,"1","1"));assertThatThrownBy(()->p.save(u,forbidden)).hasMessageContaining("nicht freigegeben");
// A failure after writing the matrix must roll back both current and historical values.
var failing=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("receipt failure")).when(failing).insert(eq("genericdatacontainer"),anyMap());var broken=new TimedPrices(failing);broken.at=p.at.plusSeconds(2);long before=db.queryForObject("SELECT COUNT(*) FROM servicehistory",Long.class);assertThatThrownBy(()->broken.save(u,save(broken,u,q,false,new Cell(1,10,"99","99")))).hasMessageContaining("receipt failure");assertThat(db.queryForObject("SELECT COUNT(*) FROM servicehistory",Long.class)).isEqualTo(before);assertThat(amount(db.queryForList("SELECT srvc_price FROM servicecustomer WHERE srv_id=1").getFirst().get("srvc_price"))).isEqualByComparingTo("25.50");
// Repeated current rows are visible but cannot be silently overwritten.
db.writeTransaction(()->db.update("INSERT INTO servicecustomer SELECT * FROM servicecustomer WHERE srv_id=1"));var cell=((List<Map<String,Object>>)p.grid(u,q).get("cells")).getFirst();assertThat(cell).containsEntry("ambiguous",true);assertThatThrownBy(()->p.save(u,save(p,u,q,false,new Cell(1,10,"30","20")))).hasMessageContaining("Bestandszeilen");
var defs=new ServiceDefinitionAdministration(db,p,new ObjectMapper().findAndRegisterModules(),"crm_qa");var dq=new Query(1,0,p.today());var create=new Change(token(),dq,Kind.PRICE,0,null,"Zusatzleistung",false,List.of(20L));var created=defs.change(u,create);long id=id(created,"id");assertThat(id).isPositive();assertThat(defs.change(u,create)).containsEntry("id",id);assertThat(db.queryForObject("SELECT COUNT(*) FROM service WHERE srv_name='Zusatzleistung'",Integer.class)).isEqualTo(1);
var d=defs.list(dq,7,Kind.PRICE).stream().filter(r->id(r,"id")==id).findFirst().orElseThrow();assertThat(d.get("groups")).isEqualTo(Set.of(20L));var rename=new Change(token(),dq,Kind.PRICE,id,text(d,"revision"),"Zusatzleistung neu",false,List.of(20L));defs.change(u,rename);assertThatThrownBy(()->defs.change(u,new Change(token(),dq,Kind.PRICE,id,text(d,"revision"),"Veraltet",false,null))).hasMessageContaining("409");
assertThatThrownBy(()->defs.change(u,new Change(token(),dq,Kind.PRICE,0,null,"Fremde Gruppe",false,List.of(999L)))).hasMessageContaining("nicht freigegeben");
var foreign=new Change(token(),dq,Kind.PRICE,4,"r","Fremd",false,null);assertThatThrownBy(()->defs.change(u,foreign)).hasMessageContaining("404");
var failedLog=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("log unavailable")).when(failedLog).update(startsWith("INSERT INTO `crm_qa`.log"),any(Object[].class));var withLogFailure=new ServiceDefinitionAdministration(failedLog,p,new ObjectMapper().findAndRegisterModules(),"crm_qa");var benefit=new Change(token(),dq,Kind.BENEFIT,0,null,"Testleistung",false,List.of());assertThat(withLogFailure.change(u,benefit)).containsEntry("state","PENDING_LOG");assertThat(db.queryForObject("SELECT COUNT(*) FROM servicetype WHERE srvt_name='Testleistung'",Integer.class)).isEqualTo(1);assertThat(defs.change(u,benefit)).containsEntry("state","DONE");assertThat(defs.pending(u)).isEmpty();
var lostAcknowledgement=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("audit acknowledgement unavailable")).when(lostAcknowledgement).update(startsWith("UPDATE genericdatacontainer SET gdc_context='DONE'"),any(Object[].class));var afterAuditFailure=new ServiceDefinitionAdministration(lostAcknowledgement,p,new ObjectMapper().findAndRegisterModules(),"crm_qa");var logged=new Change(token(),dq,Kind.BENEFIT,0,null,"Protokollwiederholung",false,List.of());assertThat(afterAuditFailure.change(u,logged)).containsEntry("state","PENDING_LOG");long logCount=db.queryForObject("SELECT COUNT(*) FROM log",Long.class);defs.change(u,logged);assertThat(db.queryForObject("SELECT COUNT(*) FROM log",Long.class)).isEqualTo(logCount);
var finalDefinition=defs.list(dq,7,Kind.PRICE).stream().filter(r->id(r,"id")==id).findFirst().orElseThrow();var remove=new Change(token(),dq,Kind.PRICE,id,text(finalDefinition,"revision"),"",true,null);defs.change(u,remove);defs.change(u,remove);assertThat(db.queryForObject("SELECT COUNT(*) FROM service WHERE srv_id=?",Integer.class,id)).isZero();assertThat(db.queryForObject("SELECT COUNT(*) FROM log WHERE logo_id=131",Integer.class)).isEqualTo(1);
}
}
@@ -0,0 +1,69 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.*;
import org.springframework.security.access.AccessDeniedException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.*;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.StatisticsService.*;
import static de.stadtbote.votian.OrderEntryService.fields;
class StatisticsServiceTest {
final DatabaseService db=mock(DatabaseService.class);
final StatisticsService service=new StatisticsService(db,new OrderEntryCatalog(db),"phoenix_log");
final LegacyUser user=new LegacyUser(11,12,7,"test","1".repeat(70));
static Query query(List<Long> hqs,List<Interval> comparisons,List<Split> splits,String number,LocalTime timeFrom,LocalTime timeTo){return new Query(new Interval(LocalDate.of(2026,1,1),LocalDate.of(2026,12,31)),comparisons,hqs,DateBasis.FINISHED,State.FINISHED,Amount.ALL,Payment.ALL,Period.MONTH,splits,number,"",null,null,true,null,null,timeFrom,timeTo,true,false);}
@BeforeEach void setup(){
when(db.readTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);if(sql.contains("target.hq_id"))return List.of(fields("hq_id",7),fields("hq_id",8));return List.of(fields("orders",1234L,"revenue",12000));});
}
@Test void fullPeriodIsAggregatedWithoutTheOrderListLimit(){
var q=query(List.of(7L),List.of(),List.of(Split.CUSTOMER,Split.HEADQUARTERS),"",null,null);
var sql=StatisticsService.sql(q,q.interval(),List.of(7L),false,"`phoenix_log`.log");
assertThat(sql.text()).contains("COUNT(*) AS orders","SUM(j.jb_totalprice)","GROUP BY period,split0,split1","LIMIT 5001").doesNotContain("LIMIT 500 ");
assertThat(sql.values()).containsExactly(7L,LocalDateTime.of(2026,1,1,0,0),LocalDateTime.of(2027,1,1,0,0));
var result=service.calculate(user,q);assertThat(result.get("completePeriod")).isEqualTo(true);
assertThat(result.toString()).contains("1234");verify(db,never()).writeTransaction(any());
}
@Test void midnightTimeWindowAndTextAreBoundParameters(){
String input="x' OR 1=1";var q=query(List.of(7L),List.of(),List.of(),input,LocalTime.of(22,0),LocalTime.of(5,0));
var sql=StatisticsService.sql(q,q.interval(),List.of(7L),false,"`phoenix_log`.log");
assertThat(sql.text()).contains("TIME(j.jb_finishtime)>=? OR TIME(j.jb_finishtime)<=?","cs.cs_eid=?").doesNotContain(input);
assertThat(sql.values()).contains(input,LocalTime.of(22,0),LocalTime.of(5,0,59));
}
@Test void retrospectiveUsesLegacyHistoryAndAuxiliaryRowsDoNotMultiplyOrders(){
var q=query(List.of(7L),List.of(),List.of(Split.START_POSTAL),"",null,null);
var sql=StatisticsService.sql(q,q.interval(),List.of(7L),false,"`history`.log");
assertThat(sql.text()).contains("EXISTS (SELECT 1 FROM `history`.log", "logo_id IN (1,25)","Nacherfassung%", "SUM(p.jbprc_price)").doesNotContain("JOIN jobprice", "JOIN tour ");
assertThat(sql.text()).contains("COUNT(*)=1 THEN MAX(v.crvh_prov) ELSE NULL", "missingCourierValues");
}
@Test void foreignMandatorAndMissingMasterRightAreRejectedBeforeAggregation(){
var q=query(List.of(9L),List.of(),List.of(),"",null,null);
assertThatThrownBy(()->service.calculate(user,q)).isInstanceOf(AccessDeniedException.class);
String rights="1".repeat(10)+"0"+"1".repeat(59);
assertThatThrownBy(()->service.calculate(new LegacyUser(11,12,7,"test",rights),query(List.of(8L),List.of(),List.of(),"",null,null))).isInstanceOf(AccessDeniedException.class);
verify(db,never()).queryForList(startsWith("SELECT 'Gesamt'"),any(Object[].class));
}
@Test void statisticsRightIsIndependentOfOrderReadRight(){
assertThatThrownBy(()->service.calculate(new LegacyUser(11,12,7,"test","000010000000"),query(List.of(),List.of(),List.of(),"",null,null))).isInstanceOf(AccessDeniedException.class);
verifyNoInteractions(db);
var onlyStatistics=new LegacyUser(11,12,7,"stats","000000001000");
assertThat(onlyStatistics.capabilities().readOrders()).isFalse();
assertThat(onlyStatistics.capabilities().readStatistics()).isTrue();
assertThat(service.calculate(onlyStatistics,query(List.of(7L),List.of(),List.of(),"",null,null))).containsEntry("completePeriod",true);
}
@Test void comparisonPeriodsAreIndependentlyBoundAndAllGetTotals(){
var prior=new Interval(LocalDate.of(2025,1,1),LocalDate.of(2025,12,31));
var result=service.calculate(user,query(List.of(7L),List.of(prior),List.of(),"",null,null));
assertThat((List<?>)result.get("periods")).hasSize(2);
verify(db,times(4)).queryForList(startsWith("SELECT "),any(Object[].class));
}
@Test void invalidRangeAndOversizedComparisonsAreRejected(){
var q=query(List.of(7L),Collections.nCopies(6,new Interval(LocalDate.now(),LocalDate.now())),List.of(),"",null,null);
assertThatThrownBy(()->service.calculate(user,q)).hasMessageContaining("fünf");verifyNoInteractions(db);
}
}
@@ -0,0 +1,19 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.ZonePriceAdministration.*;
import static de.stadtbote.votian.OrderEntryService.fields;
class ZonePriceAdministrationTest {
final DatabaseService db=mock(DatabaseService.class);final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);final ZonePriceAdministration service=new ZonePriceAdministration(db,catalog,new ObjectMapper());
@Test void allEndpointsRequireAdministrationRight(){var u=AppointmentServiceTest.user(0);assertThatThrownBy(()->service.options(u)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.grid(u,null)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.save(u,null)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.change(u,null)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.details(u,Kind.AREA,1)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);verifyNoInteractions(db,catalog);}
@Test void postalGeneralRequiresOnlyPhpRightTwoButCustomerRequiresZero(){var u=AppointmentServiceTest.user(2);assertThat(service.scope(u,new Query(Kind.POSTAL,0,0,"","",0,0))).isZero();assertThatThrownBy(()->service.scope(u,new Query(Kind.AREA,9,0,"","",0,0))).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);verifyNoInteractions(db,catalog);}
@Test void validatesDifferentLegacyColumnPrecisions(){assertThat(service.input("-0,01",Kind.POSTAL,false,false)).isEqualByComparingTo("-0.01");assertThat(service.input("9999.99",Kind.AREA,false,true)).isEqualByComparingTo("9999.99");assertThatThrownBy(()->service.input("1000",Kind.AREA,false,false)).hasMessageContaining("Wertebereich");assertThatThrownBy(()->service.input("10000",Kind.POSTAL,false,true)).hasMessageContaining("Wertebereich");assertThatThrownBy(()->service.input("1000",Kind.POSTAL,true,false)).hasMessageContaining("Wertebereich");assertThatThrownBy(()->service.input("1.001",Kind.AREA,true,false)).hasMessageContaining("Nachkommastellen");}
@Test void invalidPagesAndKindsAreRejected(){var u=AppointmentServiceTest.user(2);for(var q:List.of(new Query(null,0,0,"","",0,0),new Query(Kind.AREA,0,0,"","",-1,0),new Query(Kind.POSTAL,0,0,"12345678901","",0,0)))assertThatThrownBy(()->service.scope(u,q)).hasMessageContaining("400");verifyNoInteractions(db,catalog);}
@Test void mapsEnabledVehiclesAndRejectsHiddenSelection(){var u=AppointmentServiceTest.user(2);when(catalog.enabled(7,"SERVICE_VEHICLE_TYPE_ENABLED")).thenReturn(true);when(catalog.parameter(7,"MASK_EXCLUDE_VHT_IDS")).thenReturn("3");when(catalog.parameter(7,"SERVICE_VEHICLE_TYPE_MAPPING")).thenReturn("1=2|2=999");when(db.queryForList(startsWith("SELECT mt_sort"))).thenReturn(List.of(fields("id",1,"label","Kombi"),fields("id",2,"label","PKW"),fields("id",3,"label","Gesperrt")));assertThat(service.scope(u,new Query(Kind.AREA,0,1,"","",0,0))).isEqualTo(2);assertThat(service.scope(u,new Query(Kind.AREA,0,2,"","",0,0))).isEqualTo(2);assertThatThrownBy(()->service.scope(u,new Query(Kind.POSTAL,0,3,"","",0,0))).hasMessageContaining("nicht freigegeben");}
@Test void duplicateChangesRejectedBeforeWrite(){var u=AppointmentServiceTest.user(2);var c=new Cell(1,2,"1","0");assertThatThrownBy(()->service.save(u,new Save(UUID.randomUUID().toString(),new Query(Kind.POSTAL,0,0,"","",0,0),"r",false,List.of(c,c)))).hasMessageContaining("doppelt");verify(db,never()).writeTransaction(any());}
}
@@ -0,0 +1,41 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseService;
import java.time.*;
import java.util.*;
import static de.stadtbote.votian.ZonePriceAdministration.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.ServicePriceAdministration.amount;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
public class ZonePriceIntegrationAssertions {
static class TimedZones extends ZonePriceAdministration {LocalDateTime at=LocalDate.now().atTime(12,0);TimedZones(DatabaseService db){super(db,new OrderEntryCatalog(db),new ObjectMapper());}@Override LocalDateTime now(){return at;}}
static String token(){return UUID.randomUUID().toString();}
static Save save(TimedZones p,LegacyUser u,Query q,boolean discount,Cell c){return new Save(token(),q,text(p.grid(u,q),"revision"),discount,List.of(c));}
static Change change(TimedZones p,LegacyUser u,Kind k,long id,Action a,String name,Long... targets){return new Change(token(),k,id,id==0?"":text(p.details(u,k,id),"revision"),a,name,List.of(targets));}
@SuppressWarnings("unchecked") public static void verify(DatabaseService db){
var p=new TimedZones(db);var u=AppointmentServiceTest.user(0,2);var q=new Query(Kind.POSTAL,0,0,"","",0,0);
var grid=p.grid(u,q);assertThat((List<?>)grid.get("rows")).hasSize(10);assertThat(grid).containsEntry("moreRows",true);assertThat((List<Map<String,Object>>)p.grid(u,new Query(Kind.POSTAL,0,0,"","",10,10)).get("rows")).extracting(r->id(r,"id")).containsExactly(11L,12L);
var first=save(p,u,q,false,new Cell(1,2,"25,50","18"));assertThat(p.save(u,first)).containsEntry("changed",1);assertThat(p.save(u,first)).containsEntry("repeated",true);assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzhistory",Integer.class)).isEqualTo(1);
var current=db.queryForList("SELECT * FROM serviceplzcustomer WHERE srvp_id=1 AND srvp2_id=2").getFirst();assertThat(amount(current.get("srvpc_discount"))).isEqualByComparingTo("2");assertThat(amount(current.get("srvpc_cr_discount"))).isEqualByComparingTo("3");assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzcustomer WHERE srvp_id=2 AND srvp2_id=1",Integer.class)).isZero();
assertThatThrownBy(()->p.save(u,new Save(token(),q,first.revision(),false,List.of(new Cell(1,2,"30","20"))))).hasMessageContaining("409");assertThatThrownBy(()->p.save(u,save(p,u,q,true,new Cell(1,2,"5","4")))).hasMessageContaining("gerade gespeichert");p.at=p.at.plusSeconds(2);p.save(u,save(p,u,q,true,new Cell(1,2,"5","4")));var h=db.queryForList("SELECT * FROM serviceplzhistory ORDER BY srvph_validfrom DESC LIMIT 1").getFirst();assertThat(amount(h.get("srvph_price"))).isEqualByComparingTo("25.50");assertThat(amount(h.get("srvph_cr_price"))).isEqualByComparingTo("18");assertThat(amount(h.get("srvph_discount"))).isEqualByComparingTo("5");
var customer=new Query(Kind.POSTAL,9,0,"","",0,0);var cell=((List<Map<String,Object>>)p.grid(u,customer).get("cells")).stream().filter(c->id(c,"from")==1&&id(c,"to")==2).findFirst().orElseThrow();assertThat(cell).containsEntry("price","0.00").containsEntry("generalPrice","25.50");p.save(u,save(p,u,customer,false,new Cell(1,2,"40","22")));assertThatThrownBy(()->p.grid(u,new Query(Kind.POSTAL,99,0,"","",0,0))).hasMessageContaining("404");
var vehicle=new Query(Kind.POSTAL,0,1,"","",0,0);p.save(u,save(p,u,vehicle,false,new Cell(1,2,"45","23")));assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzcustomer WHERE vht_id=2",Integer.class)).isEqualTo(1);
var area=new Query(Kind.AREA,0,0,"","",0,0);assertThat((List<Map<String,Object>>)p.grid(u,area).get("rows")).extracting(r->id(r,"id")).containsExactly(42L,43L,44L);p.save(u,save(p,u,area,false,new Cell(42,43,"99","50")));assertThatThrownBy(()->p.save(u,save(p,u,area,false,new Cell(42,99,"1","1")))).hasMessageContaining("nicht freigegeben");
// Receipt failure rolls the current tariff and history back together.
var failing=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("receipt unavailable")).when(failing).insert(eq("genericdatacontainer"),anyMap());var broken=new TimedZones(failing);broken.at=p.at.plusSeconds(2);long before=db.queryForObject("SELECT COUNT(*) FROM serviceplzhistory",Long.class);assertThatThrownBy(()->broken.save(u,save(broken,u,q,false,new Cell(1,2,"99","50")))).hasMessageContaining("receipt unavailable");assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzhistory",Long.class)).isEqualTo(before);assertThat(amount(db.queryForList("SELECT srvpc_price FROM serviceplzcustomer WHERE cs_id=0 AND vht_id=0").getFirst().get("srvpc_price"))).isEqualByComparingTo("25.50");
var create=change(p,u,Kind.AREA,0,Action.CREATE,"Neu");p.change(u,create);p.change(u,create);assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzarea WHERE srvpa_name='Neu'",Integer.class)).isEqualTo(1);assertThatThrownBy(()->p.change(u,change(p,u,Kind.AREA,0,Action.CREATE,"Neu"))).hasMessageContaining("existiert bereits");
var rename=change(p,u,Kind.AREA,42,Action.RENAME,"Zentrum");p.change(u,rename);assertThatThrownBy(()->p.change(u,new Change(token(),Kind.AREA,42,rename.revision(),Action.RENAME,"Veraltet",List.of()))).hasMessageContaining("409");assertThatThrownBy(()->p.details(u,Kind.AREA,99)).hasMessageContaining("404");
// Same postal code can be assigned at another HQ, but only one area per HQ.
p.change(u,change(p,u,Kind.AREA,42,Action.LINK,"",1L));assertThatThrownBy(()->p.change(u,change(p,u,Kind.AREA,43,Action.LINK,"",1L))).hasMessageContaining("bereits zu einem anderen");p.change(u,change(p,u,Kind.AREA,42,Action.UNLINK,"",1L));p.change(u,change(p,u,Kind.AREA,43,Action.LINK,"",1L));assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzareamapping WHERE srvpa_id=99 AND srvp_id=1",Integer.class)).isEqualTo(1);
// Both directions and idempotency, without transitive closure.
var link=change(p,u,Kind.POSTAL,1,Action.NEIGHBOR_ADD,"",2L);p.change(u,link);p.change(u,link);assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzneighbour WHERE hq_id=7",Integer.class)).isEqualTo(2);p.change(u,change(p,u,Kind.POSTAL,2,Action.NEIGHBOR_ADD,"",3L));assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzneighbour WHERE srvp_id=1 AND srvp2_id=3 AND hq_id=7",Integer.class)).isZero();p.change(u,change(p,u,Kind.POSTAL,1,Action.NEIGHBOR_REMOVE,"",2L));assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzneighbour WHERE hq_id=8",Integer.class)).isEqualTo(2);
// Repair an old one-sided relationship and remove both directions.
db.writeTransaction(()->db.update("INSERT INTO serviceplzareaneighbour VALUES(42,43,0)"));var detail=p.details(u,Kind.AREA,42);assertThat(((List<Map<String,Object>>)detail.get("neighbors")).getFirst()).containsEntry("symmetric",false);p.change(u,change(p,u,Kind.AREA,42,Action.NEIGHBOR_ADD,"",43L));assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzareaneighbour",Integer.class)).isEqualTo(2);p.change(u,change(p,u,Kind.AREA,42,Action.NEIGHBOR_REMOVE,"",43L));assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzareaneighbour",Integer.class)).isZero();assertThatThrownBy(()->p.change(u,change(p,u,Kind.AREA,42,Action.NEIGHBOR_ADD,"",42L))).hasMessageContaining("eigene Nachbar");assertThatThrownBy(()->p.change(u,change(p,u,Kind.AREA,42,Action.NEIGHBOR_ADD,"",99L))).hasMessageContaining("404");
// Error on the reverse insert must not leave the first direction persisted.
var half=spy(db);doAnswer(inv->{long from=((Number)inv.getArgument(1)).longValue();if(from==43)throw new org.springframework.dao.DataAccessResourceFailureException("reverse unavailable");return inv.callRealMethod();}).when(half).update(startsWith("INSERT INTO serviceplzareaneighbour"),any(Object[].class));var halfService=new TimedZones(half);assertThatThrownBy(()->halfService.change(u,change(halfService,u,Kind.AREA,42,Action.NEIGHBOR_ADD,"",43L))).hasMessageContaining("reverse unavailable");assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzareaneighbour",Integer.class)).isZero();
var beforeLink=db.queryForObject("SELECT COUNT(*) FROM serviceplzareamapping",Integer.class);assertThatThrownBy(()->broken.change(u,change(broken,u,Kind.AREA,42,Action.LINK,"",2L))).hasMessageContaining("receipt unavailable");assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzareamapping",Integer.class)).isEqualTo(beforeLink);
db.writeTransaction(()->db.update("INSERT INTO serviceplzcustomer SELECT * FROM serviceplzcustomer WHERE cs_id=0 AND vht_id=0"));assertThatThrownBy(()->p.save(u,save(p,u,q,false,new Cell(1,2,"30","20")))).hasMessageContaining("Mehrere Bestandszeilen");
}
}
@@ -76,6 +76,7 @@ class DatabaseServiceTest {
@ValueSource(strings={"UPDATE job SET jb_status=2", "INSERT INTO job VALUES (?)", "DELETE FROM job", "CREATE TABLE test(id INT)", "CALL procedure_name()", "SET autocommit=1", "SELECT 1; DELETE FROM job", "SELECT 1 INTO OUTFILE '/tmp/data'", "SELECT jb_id FROM job FOR UPDATE", "/* read */ DELETE FROM job", "SELECT 1 -- comment"})
void rejectsNonReadStatementsBeforeJdbcOrTransactions(String sql) {
assertThatIllegalArgumentException().isThrownBy(()->database.queryForList(sql));
assertThatIllegalArgumentException().isThrownBy(()->database.queryMetaForList(sql));
assertThatIllegalArgumentException().isThrownBy(()->database.queryForObject(sql,Integer.class));
verifyNoInteractions(jdbc,transactions);
}
@@ -86,6 +87,192 @@ class DatabaseServiceTest {
verifyNoInteractions(jdbc,transactions);
}
@Test void explicitWriteTransactionCommitsBoundWritesAndReadsWithoutOpeningReadOnlyScopes() {
String value="quote ' ; DROP TABLE job";
when(jdbc.update("UPDATE job SET jb_commission_no=? WHERE jb_id=?",value,17)).thenReturn(1);
int count=database.writeTransaction(() -> {
database.queryForObject("SELECT jb_status FROM job WHERE jb_id=?",Integer.class,17);
return database.update("UPDATE job SET jb_commission_no=? WHERE jb_id=?",value,17);
});
assertThat(count).isEqualTo(1);
var definition=ArgumentCaptor.forClass(TransactionDefinition.class);
verify(transactions).getTransaction(definition.capture());
assertThat(definition.getValue().isReadOnly()).isFalse();
verify(transactions).commit(transaction);
assertThatThrownBy(() -> database.update("UPDATE job SET jb_status=2")).isInstanceOf(DatabaseWritesDisabledException.class);
}
@Test void stationOrPriceFailureRollsBackTheEntireWriteAndClearsTheWriteScope() {
var failure=new DataAccessResourceFailureException("fixture");
when(jdbc.update("INSERT INTO tour (jb_id) VALUES (?)",17)).thenThrow(failure);
assertThatThrownBy(() -> database.writeTransaction(() -> {
database.update("INSERT INTO job (jb_id) VALUES (?)",17);
return database.update("INSERT INTO tour (jb_id) VALUES (?)",17);
})).isSameAs(failure);
verify(transactions).rollback(transaction);verify(transactions,never()).commit(any());
assertThatThrownBy(() -> database.update("UPDATE job SET jb_status=2")).isInstanceOf(DatabaseWritesDisabledException.class);
}
@Test
@org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa")
void contactWorkflowAgainstDisposableLocalDatabase() throws Exception {
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource(System.getenv("CRM_INTEGRATION_URL"),"root","isolated-test-only");var jdbc=new JdbcTemplate(ds);
for(String table:List.of("metafieldvaluehistory","metafieldvalue","metafieldcategorykey","metafieldkey"))jdbc.execute("DROP TABLE IF EXISTS "+table);
jdbc.execute("CREATE TABLE metafieldvalue (mtfck_id INT,mtfv_id INT,mtfv_value TEXT,INDEX(mtfv_id)) ENGINE=InnoDB");jdbc.execute("CREATE TABLE metafieldvaluehistory (mtfck_id INT,mtfv_id INT,mtfv_value TEXT,mtfvh_createtime DATETIME) ENGINE=InnoDB");jdbc.execute("CREATE TABLE metafieldkey (mtfk_id INT PRIMARY KEY,mtfk_name VARCHAR(100),mtfk_type VARCHAR(50)) ENGINE=InnoDB");jdbc.execute("CREATE TABLE metafieldcategorykey (mtfck_id INT PRIMARY KEY,mtfc_id INT,mtfk_id INT,hq_id INT,mtfck_sort INT,mtfck_history INT) ENGINE=InnoDB");jdbc.update("INSERT INTO metafieldkey VALUES (101,'Name','varchar'),(129,'Bemerkung','text')");jdbc.update("INSERT INTO metafieldcategorykey VALUES (1,1,101,7,1,1),(2,1,129,7,2,1)");
var db=new DatabaseService(jdbc,new DataSourceTransactionManager(ds));
de.stadtbote.votian.ContactIntegrationAssertions.verifyContactWrites(db);
}
@Test
@org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa")
void filterWorkflowAgainstDisposableLocalDatabase() throws Exception {
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource(System.getenv("CRM_INTEGRATION_URL"),"root","isolated-test-only");var jdbc=new JdbcTemplate(ds);
for(String table:List.of("courierfilter","headquarters","parameter","genericdatacontainer","company","customer","courier","couriervehicle","log"))jdbc.execute("DROP TABLE IF EXISTS "+table);
jdbc.execute("CREATE TABLE courierfilter (crf_id INT AUTO_INCREMENT PRIMARY KEY,crf_type TINYINT NOT NULL,crf_short VARCHAR(4) NOT NULL,crf_text VARCHAR(50) NOT NULL,crf_status SMALLINT NOT NULL,vht_id INT NOT NULL,crf_sort INT NOT NULL,crf_longtext TEXT NOT NULL) ENGINE=MyISAM");
jdbc.execute("CREATE TABLE headquarters (hq_id INT PRIMARY KEY) ENGINE=InnoDB");jdbc.update("INSERT INTO headquarters VALUES(7),(8)");
jdbc.execute("CREATE TABLE parameter (par_id INT AUTO_INCREMENT PRIMARY KEY,par_key VARCHAR(70),md_id INT,hq_id INT,emp_id INT,par_value TEXT,par_text TEXT) ENGINE=InnoDB");
jdbc.execute("CREATE TABLE genericdatacontainer (gdc_id INT AUTO_INCREMENT PRIMARY KEY,gdc_obj_type VARCHAR(10),gdc_obj_id INT,gdc_gen_fieldname VARCHAR(50),gdc_content TEXT,gdc_context TEXT) ENGINE=InnoDB");
jdbc.execute("CREATE TABLE company (cmp_id INT PRIMARY KEY,cmp_archived TINYINT) ENGINE=InnoDB");jdbc.update("INSERT INTO company VALUES(1,0),(2,1)");
jdbc.execute("CREATE TABLE customer (cs_id INT PRIMARY KEY,cmp_id INT,cs_filter VARCHAR(255),cs_modify TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB");
jdbc.execute("CREATE TABLE courier (cr_id INT PRIMARY KEY,cmp_id INT,cr_filter VARCHAR(255),cr_modify TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB");
jdbc.execute("CREATE TABLE couriervehicle (crvh_id INT PRIMARY KEY,crvh_filter VARCHAR(255),crvh_modify TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB");
jdbc.execute("CREATE TABLE log (log_id INT AUTO_INCREMENT PRIMARY KEY,logo_id INT,hq_id INT,jb_id INT,usr_id INT,cr_id INT,cr_sid VARCHAR(20),cs_id INT,at_id INT,pt_id INT,emp_id INT,logo_description TEXT,log_createtime DATETIME) ENGINE=MyISAM");
var db=new DatabaseService(jdbc,new DataSourceTransactionManager(ds));de.stadtbote.votian.FilterIntegrationAssertions.verify(db);
}
@Test
@org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa")
void addressWorkflowAgainstDisposableLocalDatabase() throws Exception {
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource(System.getenv("CRM_INTEGRATION_URL"),"root","isolated-test-only");var jdbc=new JdbcTemplate(ds);
for(String table:List.of("serviceplzaddress","serviceplzareaaddress","serviceplzarea","serviceplz","address","headquarters","parameter","genericdatacontainer"))jdbc.execute("DROP TABLE IF EXISTS "+table);
jdbc.execute("CREATE TABLE headquarters (hq_id INT PRIMARY KEY) ENGINE=InnoDB");jdbc.update("INSERT INTO headquarters VALUES(7),(8)");
jdbc.execute("CREATE TABLE parameter (par_id INT AUTO_INCREMENT PRIMARY KEY,par_key VARCHAR(70),md_id INT,hq_id INT,emp_id INT,par_value TEXT,par_text TEXT) ENGINE=InnoDB");jdbc.update("INSERT INTO parameter (par_key,hq_id,emp_id,par_value) VALUES ('ZIPCODEAREA_PADLENGTH',7,0,'4'),('MASK_AREA_ID_FROM_ADDRESS_1',0,0,'1a,43')");
jdbc.execute("CREATE TABLE genericdatacontainer (gdc_id INT AUTO_INCREMENT PRIMARY KEY,gdc_obj_type VARCHAR(10),gdc_obj_id INT,gdc_gen_fieldname VARCHAR(50),gdc_content TEXT,gdc_context TEXT) ENGINE=InnoDB");
jdbc.execute("CREATE TABLE address (ad_id INT AUTO_INCREMENT PRIMARY KEY,ad_street VARCHAR(50),ad_zipcode VARCHAR(10),ad_city VARCHAR(50),ad_country VARCHAR(10)) ENGINE=InnoDB");
jdbc.execute("CREATE TABLE serviceplz (srvp_id INT PRIMARY KEY,srvp_plz VARCHAR(10),srvp_country VARCHAR(10)) ENGINE=MyISAM");jdbc.update("INSERT INTO serviceplz VALUES (1,'10000','DE'),(2,'20095','DE')");
jdbc.execute("CREATE TABLE serviceplzarea (srvpa_id INT PRIMARY KEY,hq_id INT,srvpa_name VARCHAR(10)) ENGINE=InnoDB");jdbc.update("INSERT INTO serviceplzarea VALUES(42,7,'0042'),(43,7,'0043')");
jdbc.execute("CREATE TABLE serviceplzaddress (srvpxa_id INT AUTO_INCREMENT PRIMARY KEY,hq_id INT,srvp_id INT,ad_id INT,srvpxa_hsno_from SMALLINT,srvpxa_hsno_to VARCHAR(4),srvpxa_mode SMALLINT) ENGINE=MyISAM");
jdbc.execute("CREATE TABLE serviceplzareaaddress (srvpaa_id INT AUTO_INCREMENT PRIMARY KEY,hq_id INT,srvpa_id INT,ad_id INT,srvpaa_hsno_from SMALLINT,srvpaa_hsno_to VARCHAR(4),srvpaa_mode SMALLINT) ENGINE=MyISAM");
de.stadtbote.votian.AddressIntegrationAssertions.verify(new DatabaseService(jdbc,new DataSourceTransactionManager(ds)));
}
@Test
@org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa")
void servicePricesAgainstDisposableLocalDatabase() {
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource(System.getenv("CRM_INTEGRATION_URL"),"root","isolated-test-only");var jdbc=new JdbcTemplate(ds);
for(String table:List.of("service","servicetype","servicecustomer","servicehistory","customerservice","customerservicetype","customer","parameter","headquarters","genericdatacontainer","log","groups","mandatorheadquarters"))jdbc.execute("DROP TABLE IF EXISTS "+table);
jdbc.execute("CREATE TABLE headquarters (hq_id INT PRIMARY KEY) ENGINE=InnoDB");jdbc.update("INSERT INTO headquarters VALUES(7),(8)");
jdbc.execute("CREATE TABLE parameter (par_id INT AUTO_INCREMENT PRIMARY KEY,par_key VARCHAR(70),hq_id INT,emp_id INT,par_value TEXT) ENGINE=InnoDB");jdbc.update("INSERT INTO parameter (par_key,hq_id,emp_id,par_value) VALUES ('MANDATOR_SERVICE_ENABLED',7,0,'1'),('MANDATOR_SERVICE2_ENABLED',7,0,'1')");
jdbc.execute("CREATE TABLE genericdatacontainer (gdc_id INT AUTO_INCREMENT PRIMARY KEY,gdc_obj_type VARCHAR(10),gdc_obj_id INT,gdc_gen_fieldname VARCHAR(50),gdc_content TEXT,gdc_context TEXT) ENGINE=InnoDB");
jdbc.execute("CREATE TABLE customer (cs_id INT PRIMARY KEY,hq_id INT) ENGINE=InnoDB");jdbc.update("INSERT INTO customer VALUES(9,7),(99,8)");
jdbc.execute("CREATE TABLE service (srv_id INT AUTO_INCREMENT PRIMARY KEY,srv_mode TINYINT,hq_id INT,cs_id INT,srv_name VARCHAR(50),srv_group VARCHAR(255) DEFAULT '',srv_sort SMALLINT DEFAULT 0,KEY(hq_id)) ENGINE=InnoDB");
jdbc.execute("CREATE TABLE servicetype (srvt_id INT AUTO_INCREMENT PRIMARY KEY,srvt_mode TINYINT,hq_id INT,cs_id INT,srvt_name VARCHAR(50),srvt_group VARCHAR(255) DEFAULT '',KEY(hq_id)) ENGINE=InnoDB");
jdbc.execute("CREATE TABLE customerservice (cs_id INT,srv_id INT) ENGINE=InnoDB");jdbc.execute("CREATE TABLE customerservicetype (cs_id INT,srvt_id INT) ENGINE=InnoDB");jdbc.update("INSERT INTO customerservice VALUES(9,2)");jdbc.update("INSERT INTO customerservicetype VALUES(9,11)");
jdbc.update("INSERT INTO service (srv_id,srv_mode,hq_id,cs_id,srv_name) VALUES(1,0,7,0,'Grundpreis'),(2,1,7,0,'Standard'),(3,1,7,9,'Kundenleistung'),(4,1,8,0,'Fremd')");jdbc.update("INSERT INTO servicetype (srvt_id,srvt_mode,hq_id,cs_id,srvt_name) VALUES(10,0,7,0,'PKW'),(11,1,7,0,'Stopp')");
jdbc.execute("CREATE TABLE servicecustomer (srv_id INT,srvt_id INT,hq_id INT,cs_id INT,srvc_price FLOAT(7,2),srvc_discount FLOAT(5,2),srvc_cr_price FLOAT(7,2),srvc_cr_discount FLOAT(5,2),KEY(hq_id)) ENGINE=InnoDB");jdbc.update("INSERT INTO servicecustomer VALUES(1,10,7,0,20,2,15,3)");
jdbc.execute("CREATE TABLE servicehistory (srv_id INT,srvt_id INT,hq_id INT,cs_id INT,usr_id INT,srvh_validfrom DATETIME,srvh_price FLOAT(7,2),srvh_discount FLOAT(5,2),srvh_cr_price FLOAT(7,2),srvh_cr_discount FLOAT(5,2),KEY(srv_id),KEY(srvt_id)) ENGINE=InnoDB");jdbc.update("INSERT INTO servicehistory VALUES(1,10,7,0,11,DATE_SUB(CURRENT_DATE(),INTERVAL 1 DAY),20,2,15,3)");
jdbc.execute("CREATE TABLE log (log_id INT AUTO_INCREMENT PRIMARY KEY,logo_id INT,hq_id INT,jb_id INT,usr_id INT,cr_id INT,cr_sid VARCHAR(20),cs_id INT,at_id INT,pt_id INT,emp_id INT,logo_description TEXT,log_createtime DATETIME) ENGINE=MyISAM");
jdbc.execute("CREATE TABLE groups (grp_id INT PRIMARY KEY,md_id INT,hq_id INT,grp_name VARCHAR(100),grp_readonly INT,grp_system INT) ENGINE=InnoDB");jdbc.update("INSERT INTO groups VALUES(20,3,7,'Testgruppe',0,0)");jdbc.execute("CREATE TABLE mandatorheadquarters (md_id INT,hq_id INT) ENGINE=InnoDB");jdbc.update("INSERT INTO mandatorheadquarters VALUES(3,7)");
de.stadtbote.votian.ServicePriceIntegrationAssertions.verify(new DatabaseService(jdbc,new DataSourceTransactionManager(ds)));
}
@Test
@org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa")
void zonePricesAgainstDisposableLocalDatabase() {
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource(System.getenv("CRM_INTEGRATION_URL"),"root","isolated-test-only");var jdbc=new JdbcTemplate(ds);
for(String table:List.of("headquarters","parameter","genericdatacontainer","customer","metatype","serviceplz","serviceplzarea","serviceplzcustomer","serviceplzareacustomer","serviceplzhistory","serviceplzareahistory","serviceplzneighbour","serviceplzareaneighbour","serviceplzareamapping"))jdbc.execute("DROP TABLE IF EXISTS "+table);
jdbc.execute("CREATE TABLE headquarters (hq_id INT PRIMARY KEY) ENGINE=InnoDB");jdbc.update("INSERT INTO headquarters VALUES(7),(8)");
jdbc.execute("CREATE TABLE parameter (par_id INT AUTO_INCREMENT PRIMARY KEY,par_key VARCHAR(70),hq_id INT,emp_id INT,par_value TEXT) ENGINE=InnoDB");jdbc.update("INSERT INTO parameter (par_key,hq_id,emp_id,par_value) VALUES ('SERVICE_VEHICLE_TYPE_ENABLED',7,0,'1'),('SERVICE_VEHICLE_TYPE_MAPPING',7,0,'1=2'),('MASK_EXCLUDE_VHT_IDS',7,0,'3')");
jdbc.execute("CREATE TABLE genericdatacontainer (gdc_id INT AUTO_INCREMENT PRIMARY KEY,gdc_obj_type VARCHAR(10),gdc_obj_id INT,gdc_gen_fieldname VARCHAR(50),gdc_content TEXT,gdc_context TEXT) ENGINE=InnoDB");
jdbc.execute("CREATE TABLE customer (cs_id INT PRIMARY KEY,hq_id INT) ENGINE=InnoDB");jdbc.update("INSERT INTO customer VALUES(9,7),(99,8)");
jdbc.execute("CREATE TABLE metatype (mt_type VARCHAR(50),mt_sort INT,mt_value VARCHAR(50)) ENGINE=InnoDB");jdbc.update("INSERT INTO metatype VALUES('vehicletype',1,'Kombi'),('vehicletype',2,'PKW'),('vehicletype',3,'Gesperrt')");
jdbc.execute("CREATE TABLE serviceplz (srvp_id INT PRIMARY KEY,srvp_plz VARCHAR(10),srvp_country VARCHAR(10),srvp_valid INT) ENGINE=MyISAM");for(int i=1;i<=12;i++)jdbc.update("INSERT INTO serviceplz VALUES(?,?,?,1)",i,"10"+String.format("%03d",i),"DE");jdbc.update("INSERT INTO serviceplz VALUES(100,'00000','DE',0)");
jdbc.execute("CREATE TABLE serviceplzarea (srvpa_id INT AUTO_INCREMENT PRIMARY KEY,hq_id INT,srvpa_name VARCHAR(10)) ENGINE=InnoDB");jdbc.update("INSERT INTO serviceplzarea VALUES(42,7,'0042'),(43,7,'0043'),(44,7,'0044'),(99,8,'Fremd')");
for(String kind:List.of("serviceplz","serviceplzarea")) {String p=kind.equals("serviceplz")?"srvp":"srvpa",precision=kind.equals("serviceplz")?"6":"5";
jdbc.execute("CREATE TABLE "+kind+"customer ("+p+"_id INT,"+p+"2_id INT,hq_id INT,cs_id INT,vht_id INT,"+p+"c_price FLOAT("+precision+",2),"+p+"c_discount FLOAT(5,2),"+p+"c_cr_price FLOAT(6,2),"+p+"c_cr_discount FLOAT(5,2),KEY(hq_id)) ENGINE=InnoDB");
jdbc.execute("CREATE TABLE "+kind+"history ("+p+"_id INT NOT NULL,"+p+"2_id INT NOT NULL,hq_id INT NOT NULL,cs_id INT NOT NULL,vht_id INT NOT NULL,usr_id INT,"+p+"h_validfrom DATETIME NOT NULL,"+p+"h_price FLOAT("+precision+",2),"+p+"h_discount FLOAT(5,2),"+p+"h_cr_price FLOAT(6,2),"+p+"h_cr_discount FLOAT(5,2)"+(kind.equals("serviceplz")?",PRIMARY KEY (srvp_id,srvp2_id,hq_id,cs_id,vht_id,srvph_validfrom)":",KEY(hq_id)")+") ENGINE=InnoDB");
}
jdbc.update("INSERT INTO serviceplzcustomer VALUES(1,2,7,0,0,20,2,15,3)");
jdbc.execute("CREATE TABLE serviceplzneighbour (hq_id INT,srvp_id INT,srvp2_id INT,srvpn_sort SMALLINT) ENGINE=InnoDB");jdbc.update("INSERT INTO serviceplzneighbour VALUES(8,1,2,0),(8,2,1,0)");
jdbc.execute("CREATE TABLE serviceplzareaneighbour (srvpa_id INT,srvpa2_id INT,srvpan_sort SMALLINT) ENGINE=InnoDB");
jdbc.execute("CREATE TABLE serviceplzareamapping (srvpa_id INT,srvp_id INT) ENGINE=InnoDB");jdbc.update("INSERT INTO serviceplzareamapping VALUES(99,1)");
de.stadtbote.votian.ZonePriceIntegrationAssertions.verify(new DatabaseService(jdbc,new DataSourceTransactionManager(ds)));
}
@Test
@org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa")
void businessWorkflowsAgainstDisposableLocalDatabase() throws Exception {
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource(System.getenv("CRM_INTEGRATION_URL"),"root","isolated-test-only");
var jdbc=new JdbcTemplate(ds);
try(var connection=ds.getConnection()) {
org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript(connection,new org.springframework.core.io.ClassPathResource("business-schema.sql"));
}
de.stadtbote.votian.BusinessIntegrationAssertions.verify(new DatabaseService(jdbc,new DataSourceTransactionManager(ds)));
}
@Test
@org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa")
void orderRemaindersAgainstDisposableLocalDatabase() throws Exception {
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource(System.getenv("CRM_INTEGRATION_URL"),"root","isolated-test-only");var jdbc=new JdbcTemplate(ds);
try(var connection=ds.getConnection()) {for(String script:java.util.List.of("business-schema.sql","order-inventory-schema.sql"))org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript(connection,new org.springframework.core.io.ClassPathResource(script));}
de.stadtbote.votian.OrderRemainderIntegrationAssertions.verify(new DatabaseService(jdbc,new DataSourceTransactionManager(ds)));
}
@Test
@org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa")
void groupDeletionAgainstDisposableLocalDatabase() throws Exception {
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource(System.getenv("CRM_INTEGRATION_URL"),"root","isolated-test-only");var jdbc=new JdbcTemplate(ds);
for(String sql:de.stadtbote.votian.GroupDeletionIntegrationAssertions.setup())jdbc.execute(sql);
de.stadtbote.votian.GroupDeletionIntegrationAssertions.verify(new DatabaseService(jdbc,new DataSourceTransactionManager(ds)));
}
@Test
@org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa")
void reportFormsAgainstDisposableLocalDatabase() throws Exception {
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource(System.getenv("CRM_INTEGRATION_URL"),"root","isolated-test-only");var jdbc=new JdbcTemplate(ds);
for(String sql:de.stadtbote.votian.ReportFormsIntegrationAssertions.setup())jdbc.execute(sql);
de.stadtbote.votian.ReportFormsIntegrationAssertions.verify(new DatabaseService(jdbc,new DataSourceTransactionManager(ds)));
}
@Test
@org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable(named="CRM_INTEGRATION_URL",matches="jdbc:mariadb://127[.]0[.]0[.]1:33079/crm_qa")
void customerServicesAgainstDisposableLocalDatabase() throws Exception {
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource(System.getenv("CRM_INTEGRATION_URL"),"root","isolated-test-only");var jdbc=new JdbcTemplate(ds);
for(String sql:de.stadtbote.votian.CustomerServicesIntegrationAssertions.setup())jdbc.execute(sql);
de.stadtbote.votian.CustomerServicesIntegrationAssertions.verify(new DatabaseService(jdbc,new DataSourceTransactionManager(ds)));
}
@Test void contactWritesRefuseNontransactionalTables() {
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM information_schema.TABLES"),eq(Integer.class),any(Object[].class))).thenReturn(3);
assertThatThrownBy(()->database.contactWriteTransaction(()->database.update("DELETE FROM metafieldvalue"))).hasMessageContaining("InnoDB");
verify(jdbc,never()).execute(anyString());verify(jdbc,never()).update(anyString());
}
@Test void contactLocksCommitBeforeUnlockAndRollbackBeforeUnlockOnFailure() {
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM information_schema.TABLES"),eq(Integer.class),any(Object[].class))).thenReturn(4);
database.contactWriteTransaction(() -> database.update("INSERT INTO metafieldvalue (mtfv_id) VALUES (?)",1));
var order=inOrder(jdbc);
order.verify(jdbc).execute(startsWith("LOCK TABLES metafieldvalue WRITE"));
order.verify(jdbc).update("INSERT INTO metafieldvalue (mtfv_id) VALUES (?)",new Object[]{1});
order.verify(jdbc).execute("COMMIT");order.verify(jdbc).execute("UNLOCK TABLES");
clearInvocations(jdbc);
assertThatThrownBy(()->database.contactWriteTransaction(()->{throw new IllegalStateException("fixture");})).hasMessage("fixture");
order=inOrder(jdbc);order.verify(jdbc).execute(startsWith("LOCK TABLES"));order.verify(jdbc).execute("ROLLBACK");order.verify(jdbc).execute("UNLOCK TABLES");
}
@Test void lockingRequiresWriteScopeAndDoesNotLeakThroughTheReadApi() {
assertThatThrownBy(() -> database.lockRows("SELECT usr_id FROM user WHERE usr_id=?",11)).isInstanceOf(DatabaseWritesDisabledException.class);
database.writeTransaction(() -> database.lockRows("SELECT usr_id FROM user WHERE usr_id=?",11));
verify(jdbc).queryForList("SELECT usr_id FROM user WHERE usr_id=? FOR UPDATE",11);
}
@ParameterizedTest @ValueSource(strings={"CREATE TABLE example(id INT)","DROP TABLE job","SET foreign_key_checks=0","UPDATE job SET jb_status=0; DELETE FROM job"})
void writeScopeStillRejectsDdlAndMultipleStatements(String sql) {
assertThatIllegalArgumentException().isThrownBy(() -> database.writeTransaction(() -> database.update(sql)));
verifyNoInteractions(jdbc);verify(transactions).rollback(transaction);
}
@Test void failedQueriesRollbackAndDoNotReturnAnEmptyReplacement() {
var failure=new DataAccessResourceFailureException("private database detail");
when(jdbc.queryForList("SELECT jb_id FROM job",new Object[0])).thenThrow(failure);
@@ -0,0 +1,68 @@
DROP TABLE IF EXISTS job;
CREATE TABLE job (`jb_id` int(10) NOT NULL auto_increment,`csc_id` int(10) NOT NULL DEFAULT 0,`hq_id` int(10) NOT NULL DEFAULT 0,`hq_id_exec` int(10) NULL DEFAULT 0,`hq_id_dispo` int(10) NOT NULL DEFAULT 0,`hq_id_sales` int(10) NOT NULL DEFAULT 0,`vht_id` int(10) NOT NULL DEFAULT 0,`vht_id_real` int(10) NOT NULL DEFAULT 0,`csc_id_payer` int(10) NOT NULL DEFAULT 0,`csc_id_payer_cash` int(10) NOT NULL DEFAULT 0,`csc_id_related` int(10) NOT NULL DEFAULT 0,`jb_payment` int(1) NOT NULL DEFAULT 0,`jb_outlay` int(5) NOT NULL DEFAULT 0,`jb_weight` int(3) NOT NULL DEFAULT 0,`jb_crvh_length` int(10) NOT NULL DEFAULT 0,`jb_crvh_width` int(10) NOT NULL DEFAULT 0,`jb_crvh_height` int(10) NOT NULL DEFAULT 0,`jb_crvh_position` int(10) NOT NULL DEFAULT 0,`jb_ordertime` datetime NULL DEFAULT NULL,`jb_reserv` int(1) NULL DEFAULT NULL,`jb_permanent` int(1) NULL DEFAULT NULL,`jb_permanent2` varchar(7) NOT NULL DEFAULT '0000000',`jp_permenddat` datetime NULL DEFAULT NULL,`jb_specials` varchar(20) NULL DEFAULT NULL,`cr_id` int(10) NULL DEFAULT NULL,`cr_sid` varchar(10) NULL DEFAULT NULL,`cr_id_order` int(10) NULL DEFAULT NULL,`cr_id_permanent` varchar(10) NULL DEFAULT NULL,`jb_cr_filter` varchar(255) NULL DEFAULT NULL,`jb_cr_filter_opt` varchar(255) NULL DEFAULT NULL,`jb_cr_fav` tinyint(1) unsigned NOT NULL DEFAULT 0,`jb_waitstorno` int(1) NULL DEFAULT NULL,`jb_waittime` datetime NULL DEFAULT NULL,`jb_taketime` datetime NULL DEFAULT NULL,`jb_tourname` varchar(50) NULL DEFAULT NULL,`jb_status` int(1) NOT NULL DEFAULT 0,`jb_type` smallint(2) NOT NULL DEFAULT 0,`jb_incomplete` int(1) NULL DEFAULT NULL,`jb_globaljob` int(1) NULL DEFAULT NULL,`jb_longhaul` smallint(1) NULL DEFAULT 0,`jb_longhaul_export` tinyint(1) NULL DEFAULT 0,`jb_autoranking` smallint(1) NOT NULL DEFAULT 0,`jb_storno` int(1) NULL DEFAULT NULL,`usr_id_storno` int(10) NULL DEFAULT NULL,`jb_finishtime` datetime NULL DEFAULT NULL,`jb_warningtime` datetime NULL DEFAULT NULL,`emp_id` int(10) NULL DEFAULT 0,`jb_fixprice` float(10,4) NOT NULL DEFAULT 0.0000,`jb_serviceprice` float(10,4) NOT NULL DEFAULT 0.0000,`jb_totalprice` float(10,4) NOT NULL DEFAULT 0.0000,`jb_cr_price` float(10,4) NOT NULL DEFAULT 0.0000,`jb_hq_id_exec_price` float(10,4) NULL DEFAULT 0.0000,`jb_postage` float(5,2) NOT NULL DEFAULT 0.00,`jb_subtotalprice` float(10,4) NOT NULL DEFAULT 0.0000,`jb_cr_subprice` float(10,4) NOT NULL DEFAULT 0.0000,`jb_markup` float(5,2) NOT NULL DEFAULT 0.00,`jb_invmode` int(1) NOT NULL DEFAULT 0,`jb_invtext` text NULL DEFAULT NULL,`jb_invtext_cr` text NULL DEFAULT NULL,`jb_mediationarea_id` mediumint(9) NULL DEFAULT NULL,`jb_mediationarea_name` varchar(20) NULL DEFAULT NULL,`jb_sales_tax_rate` smallint(2) NULL DEFAULT NULL,`jb_sales_tax_rate_sign` varchar(10) NULL DEFAULT NULL,`jb_freetext_1` text NULL DEFAULT NULL,`jb_freetext_2` text NULL DEFAULT NULL,`jb_freetext_3` text NULL DEFAULT NULL,`jb_tourdata` text NULL DEFAULT NULL,`jb_createtime` datetime NOT NULL,`jb_modify` timestamp NOT NULL DEFAULT current_timestamp() on update current_timestamp(),`jb_locktime` datetime NULL DEFAULT NULL,`jb_lockuser` int(11) NULL DEFAULT NULL,`jb_id_parent` int(10) NULL DEFAULT NULL,`jb_id_copy` int(11) NULL DEFAULT NULL,`jb_id_copy_permanent` int(11) NULL DEFAULT NULL,`jb_automailsent` smallint(1) NOT NULL DEFAULT 0,`jb_automailsent2` smallint(1) NOT NULL DEFAULT 0,`jb_automailsent3` smallint(1) NOT NULL DEFAULT 0,`jb_cr_remark` varchar(255) NULL DEFAULT NULL,`jb_dispoinfo` tinytext NULL DEFAULT NULL,`jb_offer` smallint(6) NOT NULL DEFAULT 0,`jb_timeunits` tinyint(3) NOT NULL DEFAULT 2,`jb_service` varchar(10) NOT NULL,`jb_origin` tinyint(1) unsigned NOT NULL DEFAULT 0,`jb_orderer` varchar(50) NOT NULL,`jb_commission_no` varchar(50) NULL DEFAULT NULL,`jb_version` smallint(5) unsigned NOT NULL DEFAULT 0,`jb_booktime` datetime NULL DEFAULT NULL,`jb_export_time` datetime NULL DEFAULT NULL,`jb_app_version` int(10) unsigned NULL DEFAULT NULL,`jb_scrm` varchar(36) NOT NULL,PRIMARY KEY (`jb_id`)) ENGINE=InnoDB;
DROP TABLE IF EXISTS tour;
CREATE TABLE tour (`tr_id` int(10) NOT NULL auto_increment,`jb_id` int(10) NOT NULL DEFAULT 0,`ad_id` int(10) NOT NULL DEFAULT 0,`tr_sort` int(3) NOT NULL DEFAULT 0,`tr_comp` varchar(50) NOT NULL DEFAULT '',`tr_comp2` varchar(50) NULL DEFAULT NULL,`tr_hsno` varchar(10) NOT NULL DEFAULT '',`tr_floor` smallint(3) NOT NULL DEFAULT 0,`tr_remark` text NULL DEFAULT NULL,`tr_person` varchar(50) NULL DEFAULT NULL,`tr_phone` varchar(20) NULL DEFAULT NULL,`csc_id` int(10) NULL DEFAULT NULL,`tr_km_extra` int(5) NOT NULL DEFAULT 0,`tr_srv_extra` int(3) NOT NULL DEFAULT 0,`tr_status` int(1) NULL DEFAULT NULL,`tr_modify` timestamp NOT NULL DEFAULT current_timestamp() on update current_timestamp(),`tr_sign` text NULL DEFAULT NULL,`tr_signname` varchar(50) NULL DEFAULT NULL,`tr_ware_from_to` smallint(1) NULL DEFAULT NULL,`tr_finishtime` datetime NULL DEFAULT NULL,`tr_commission_no` varchar(30) NOT NULL DEFAULT '',`tr_mediationarea_id` mediumint(9) NULL DEFAULT NULL,`tr_mediationarea_name` varchar(20) NULL DEFAULT NULL,`tr_cs_freetext` text NOT NULL,PRIMARY KEY (`tr_id`)) ENGINE=InnoDB;
DROP TABLE IF EXISTS tourservice;
CREATE TABLE tourservice (`jb_id` int(10) NOT NULL DEFAULT 0,`csc_id` int(10) NOT NULL DEFAULT 0,`tr_sort` int(2) NOT NULL DEFAULT 0,`srv_id` int(10) NOT NULL DEFAULT 0,`trs_srv_name` varchar(50) NOT NULL DEFAULT '',`srvt_id` int(10) NOT NULL DEFAULT 0,`trs_srvt_name` varchar(50) NOT NULL DEFAULT '',`trs_price` float(10,4) NOT NULL DEFAULT 0.0000,`trs_discount` float(8,4) NULL DEFAULT 0.0000,`trs_modify` timestamp NOT NULL DEFAULT current_timestamp() on update current_timestamp()) ENGINE=InnoDB;
DROP TABLE IF EXISTS jobpayment;
CREATE TABLE jobpayment (`jbp_id` int(10) NOT NULL auto_increment,`jb_id` int(10) NOT NULL DEFAULT 0,`tr_sort` int(3) NOT NULL DEFAULT 0,`csc_id` int(10) NOT NULL DEFAULT 0,`jbp_mode` tinyint(2) NOT NULL DEFAULT 0,`jbp_price` float(10,4) NOT NULL DEFAULT 0.0000,`jbp_bookingtime` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',`jbp_counter` tinyint(1) NOT NULL DEFAULT 0,`jbp_tan` varchar(20) NOT NULL DEFAULT '',`jbpc_id` int(10) NOT NULL DEFAULT 0,`jbp_export_time` datetime NULL DEFAULT NULL,PRIMARY KEY (`jbp_id`)) ENGINE=InnoDB;
DROP TABLE IF EXISTS company;
CREATE TABLE company (`cmp_id` int(10) NOT NULL auto_increment,`cmp_type` tinyint(4) NOT NULL DEFAULT 0,`cmp_comp` varchar(50) NOT NULL DEFAULT '',`cmp_comp2` varchar(50) NULL DEFAULT NULL,`cmp_comp3` varchar(50) NOT NULL,`cmp_comp4` varchar(50) NOT NULL,`ad_id` int(10) NOT NULL DEFAULT 0,`cmp_hsno` varchar(10) NOT NULL DEFAULT '',`cmp_iln` varchar(30) NULL DEFAULT NULL,`cmp_tax_idno` varchar(30) NULL DEFAULT NULL,`cmp_stax_idno` varchar(30) NULL DEFAULT NULL,`tx_id` int(10) NOT NULL DEFAULT 0,`br_id` int(10) NOT NULL DEFAULT 0,`emp_id_sales` int(10) NOT NULL DEFAULT 0,`cmp_fingerprint` varchar(50) NOT NULL,`cmp_sales_ranking_man` tinyint(2) NOT NULL DEFAULT 0,`cmp_bank` varchar(30) NOT NULL DEFAULT '',`cmp_bankno` varchar(20) NOT NULL DEFAULT '',`cmp_bankacc` varchar(20) NOT NULL DEFAULT '',`cmp_bankmode` int(1) NOT NULL DEFAULT 0,`cmp_iban` varchar(40) NOT NULL DEFAULT '',`cmp_swift` varchar(20) NOT NULL DEFAULT '',`cmp_postage` float(5,2) NULL DEFAULT 0.00,`cmp_hqlino` varchar(20) NOT NULL DEFAULT '',`cmp_gln` varchar(20) NOT NULL,`cmp_inv2hq` smallint(1) NOT NULL DEFAULT 0,`cmp_authenticated` smallint(1) NOT NULL DEFAULT 0,`cmp_visible` smallint(1) NOT NULL DEFAULT 0,`cmp_visible_internal` tinyint(1) NOT NULL DEFAULT 0,`cmp_archived` tinyint(1) NOT NULL DEFAULT 0,`cmp_no_longhaul` tinyint(1) NOT NULL DEFAULT 0,`cmp_cashmode` smallint(1) NOT NULL DEFAULT 0,`cmp_keyuser` varchar(30) NULL DEFAULT NULL,`cmp_publickey` text NULL DEFAULT NULL,`cmp_defaultkey` varchar(30) NULL DEFAULT NULL,`cmp_modify` timestamp NOT NULL DEFAULT current_timestamp() on update current_timestamp(),`cmp_logo` varchar(30) NULL DEFAULT NULL,`cmp_logo_width` char(3) NULL DEFAULT NULL,`cmp_logo_height` char(3) NULL DEFAULT NULL,`cmp_remark` text NULL DEFAULT NULL,`cmp_remark2` text NULL DEFAULT NULL,`cmp_match` varchar(200) NULL DEFAULT NULL,`cmp_match2` varchar(50) NULL DEFAULT NULL,`cmp_sustainability_email` varchar(250) NOT NULL,`cmp_sustainability_interval` tinyint(1) NOT NULL DEFAULT 0,`cmp_new_date` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',`cmp_contract_date` date NOT NULL,`cmp_dpf_state` tinyint(1) NOT NULL DEFAULT 0,`cmp_dpf_datetime` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',`cmp_dpf_checksum` varchar(50) NOT NULL DEFAULT '',`cmp_dsgvo` tinyint(1) NOT NULL DEFAULT 0,`cmp_newsletter` tinyint(1) NOT NULL DEFAULT 1,`cmp_proof_delivery` tinyint(1) NOT NULL DEFAULT 0,`cmp_pda_usage` tinyint(1) NOT NULL DEFAULT 1,`cmp_freetext1` text NOT NULL,`cmp_modify_status` int(1) NOT NULL DEFAULT 0,`cmp_export_time` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',PRIMARY KEY (`cmp_id`)) ENGINE=InnoDB;
DROP TABLE IF EXISTS customer;
CREATE TABLE customer (`cs_id` int(10) NOT NULL auto_increment,`cmp_id` int(10) NOT NULL DEFAULT 0,`hq_id` int(10) NOT NULL DEFAULT 0,`cs_eid` varchar(10) NOT NULL DEFAULT '',`cs_sid` varchar(10) NULL DEFAULT NULL,`csc_id` int(10) NOT NULL DEFAULT 0,`csc_id_payer` int(10) NULL DEFAULT NULL,`cs_admin` int(10) NOT NULL DEFAULT 0,`cs_id_parent` int(10) NULL DEFAULT NULL,`cs_id_related` int(10) NOT NULL DEFAULT 0,`cs_discount` float(5,2) NULL DEFAULT 0.00,`cs_fixprice_discount` smallint(1) NOT NULL DEFAULT 0,`cs_prov` float(5,2) NOT NULL DEFAULT 0.00,`cs_invmode` int(1) NOT NULL DEFAULT 0,`cs_commission_no` smallint(1) NOT NULL DEFAULT 0,`cs_jb_commission_no` smallint(1) NOT NULL DEFAULT 0,`cs_cr_fav_only` smallint(1) NOT NULL DEFAULT 0,`cs_jbstatusmail2csc` tinyint(1) NOT NULL DEFAULT 0,`cs_jbstatusmail_price` tinyint(1) NOT NULL DEFAULT 1,`cs_jbstatusmail_emp` tinyint(1) NOT NULL DEFAULT 0,`cs_jbstatusmail_pdf` tinyint(1) NOT NULL DEFAULT 0,`cs_jbstatusmail_fields` varchar(30) NOT NULL DEFAULT '',`cs_jbstatusmail` int(1) NULL DEFAULT NULL,`cs_jbstatusmail2` int(1) NULL DEFAULT NULL,`cs_jbstatusmail3` int(1) NULL DEFAULT NULL,`cs_jb_jam_waittime` tinyint(3) NOT NULL DEFAULT 0,`cs_jb_jam_waittime_minutes` tinyint(4) NOT NULL DEFAULT 0,`cs_jbedit` smallint(1) NOT NULL DEFAULT 0,`cs_jbedit_cr` smallint(1) NOT NULL DEFAULT 0,`cs_ar` tinyint(1) NOT NULL DEFAULT 1,`cs_jbcond` tinyint(1) NOT NULL DEFAULT 0,`cs_jobbatch` smallint(1) NOT NULL DEFAULT 0,`cs_invtext_mode` smallint(1) NOT NULL DEFAULT 0,`cs_filter` varchar(255) NULL DEFAULT NULL,`cs_filter_jbedit` varchar(255) NULL DEFAULT NULL,`cs_markup` smallint(1) NOT NULL DEFAULT 0,`cs_markup_prov` float(5,2) NOT NULL DEFAULT 0.00,`cs_group` varchar(255) NULL DEFAULT NULL,`cs_price_warn_uncomplete` tinyint(1) NOT NULL DEFAULT 0,`cs_joblist_disabled` tinyint(1) NOT NULL DEFAULT 0,`cs_become_cs_date` date NOT NULL,`cs_repayment` float(5,2) NOT NULL DEFAULT 0.00,`cs_tracking` tinyint(4) NOT NULL DEFAULT 0,`cs_charging_time` tinyint(3) NOT NULL DEFAULT 0,`cs_blur_markup_time` tinyint(3) NOT NULL DEFAULT 0,`cs_payment_term` tinyint(2) NOT NULL DEFAULT 0,`cs_modify` timestamp NOT NULL DEFAULT current_timestamp() on update current_timestamp(),PRIMARY KEY (`cs_id`)) ENGINE=InnoDB;
DROP TABLE IF EXISTS costcenter;
CREATE TABLE costcenter (`csc_id` int(10) NOT NULL auto_increment,`cs_id` int(10) NOT NULL DEFAULT 0,`csc_name` varchar(50) NOT NULL DEFAULT '',`csc_pre_id` int(10) NULL DEFAULT NULL,`csc_path` text NULL DEFAULT NULL,`csc_is_extern` int(1) NOT NULL DEFAULT 0,`csc_blocked` int(1) NULL DEFAULT NULL,`csc_visible` smallint(1) NOT NULL DEFAULT 0,`csc_inv_interval` int(1) NULL DEFAULT NULL,`csc_inv_type` int(1) NULL DEFAULT NULL,`csc_modify` timestamp NOT NULL DEFAULT current_timestamp() on update current_timestamp(),PRIMARY KEY (`csc_id`)) ENGINE=InnoDB;
DROP TABLE IF EXISTS costcenteraddress;
CREATE TABLE costcenteraddress (`csc_id` int(10) NOT NULL DEFAULT 0,`cscad_comp` varchar(50) NOT NULL DEFAULT '',`cscad_comp2` varchar(50) NULL DEFAULT NULL,`cscad_comp3` varchar(50) NOT NULL,`cscad_comp4` varchar(50) NOT NULL,`ad_id` int(10) NOT NULL DEFAULT 0,`adt_id` int(10) NOT NULL DEFAULT 0,`cscad_hsno` varchar(10) NOT NULL DEFAULT '',`cscad_floor` smallint(3) NULL DEFAULT 0,`cscad_remark` text NULL DEFAULT NULL,`cscad_person` varchar(50) NULL DEFAULT NULL,`cscad_phone` varchar(20) NULL DEFAULT NULL,`cscad_email` varchar(100) NOT NULL DEFAULT '',`cscad_gln` varchar(20) NOT NULL,`cscad_modify` timestamp NOT NULL DEFAULT current_timestamp() on update current_timestamp()) ENGINE=InnoDB;
DROP TABLE IF EXISTS user;
CREATE TABLE user (`usr_id` int(10) NOT NULL auto_increment,`hq_id` int(10) NOT NULL DEFAULT 0,`usr_type` smallint(1) NOT NULL DEFAULT 0,`usr_name` varchar(50) NOT NULL DEFAULT '',`usr_firstname` varchar(50) NOT NULL DEFAULT '',`usr_birthdate` date NULL DEFAULT NULL,`usr_country` varchar(10) NOT NULL,`ad_id` int(10) NULL DEFAULT NULL,`usr_hsno` varchar(10) NULL DEFAULT NULL,`usr_email` varchar(250) NOT NULL DEFAULT '',`usr_inv_email` varchar(250) NOT NULL,`usr_reminder_email` varchar(250) NOT NULL,`usr_phone` varchar(20) NULL DEFAULT '0',`usr_phone2` varchar(20) NULL DEFAULT NULL,`usr_fax` varchar(20) NULL DEFAULT NULL,`usr_account` varchar(20) NOT NULL DEFAULT '',`usr_password_old` varchar(255) NOT NULL,`usr_password` varchar(255) NULL DEFAULT NULL,`usr_password_modify` datetime NULL DEFAULT NULL,`usr_totp_secret` varchar(255) NOT NULL,`usr_totp_activated` tinyint(4) NOT NULL DEFAULT 0,`usr_totp_sessionkey` varchar(32) NOT NULL,`usr_modify` timestamp NOT NULL DEFAULT current_timestamp() on update current_timestamp(),PRIMARY KEY (`usr_id`)) ENGINE=InnoDB;
DROP TABLE IF EXISTS employee;
CREATE TABLE employee (`emp_id` int(10) NOT NULL auto_increment,`usr_id` int(10) NOT NULL DEFAULT 0,`csc_id` int(10) NOT NULL DEFAULT 0,`emp_group` varchar(255) NULL DEFAULT NULL,`emp_rights` varchar(70) NULL DEFAULT NULL,`emp_heritable_rights` varchar(70) NULL DEFAULT NULL,`emp_headquarters` varchar(255) NOT NULL,`emp_modify` timestamp NOT NULL DEFAULT current_timestamp() on update current_timestamp(),PRIMARY KEY (`emp_id`)) ENGINE=InnoDB;
DROP TABLE IF EXISTS courier;
CREATE TABLE courier (`cr_id` int(10) NOT NULL auto_increment,`cmp_id` int(10) NOT NULL DEFAULT 0,`hq_id` int(10) NOT NULL DEFAULT 0,`usr_id` int(10) NOT NULL DEFAULT 0,`cr_eid` varchar(10) NOT NULL DEFAULT '',`cr_sid` varchar(10) NOT NULL DEFAULT '',`cr_id_parent` int(10) NULL DEFAULT NULL,`vht_id` int(10) NOT NULL DEFAULT 0,`vht_id_inv` int(10) NOT NULL DEFAULT 0,`cr_filter` varchar(255) NOT NULL,`cr_vh_sign` varchar(10) NULL DEFAULT NULL,`cr_imei` varchar(20) NULL DEFAULT NULL,`cr_serialno` varchar(30) NOT NULL,`cr_mobile_pda` varchar(30) NULL DEFAULT NULL,`cr_device_info` varchar(128) NOT NULL,`cr_maxweight` int(3) NOT NULL DEFAULT 0,`cr_outlay` int(5) NOT NULL DEFAULT 0,`cr_locationzipcode` varchar(10) NULL DEFAULT NULL,`cr_gps_long_home` float(9,6) NOT NULL DEFAULT 0.000000,`cr_gps_lat_home` float(9,6) NOT NULL DEFAULT 0.000000,`cr_gps_long_desired` float(9,6) NOT NULL DEFAULT 0.000000,`cr_gps_lat_desired` float(9,6) NOT NULL DEFAULT 0.000000,`cr_gps_long_reserv` float(9,6) NOT NULL DEFAULT 0.000000,`cr_gps_lat_reserv` float(9,6) NOT NULL DEFAULT 0.000000,`cr_ar_jb_id` int(10) NOT NULL DEFAULT 0,`cr_available` int(1) NULL DEFAULT NULL,`cr_availabletime` datetime NULL DEFAULT NULL,`cr_occupied` int(3) NOT NULL DEFAULT 0,`cr_logintime` datetime NULL DEFAULT NULL,`cr_gps_long` float(9,6) NULL DEFAULT 0.000000,`cr_gps_lat` float(9,6) NULL DEFAULT 0.000000,`cr_gps_time` datetime NULL DEFAULT NULL,`cr_gps_type` tinyint(4) NOT NULL DEFAULT 0,`cr_gps_acc` int(11) NULL DEFAULT NULL,`cr_mobile_cell` varchar(128) NOT NULL,`cr_group` varchar(255) NULL DEFAULT NULL,`cr_msggrp` varchar(255) NULL DEFAULT NULL,`cr_disposition` tinyint(1) NOT NULL DEFAULT 0,`cr_price_show` tinyint(1) NULL DEFAULT 0,`cr_app_blocked` tinyint(1) NOT NULL DEFAULT 0,`cr_modify` timestamp NOT NULL DEFAULT current_timestamp() on update current_timestamp(),PRIMARY KEY (`cr_id`)) ENGINE=InnoDB;
DROP TABLE IF EXISTS couriervehicle;
CREATE TABLE couriervehicle (`crvh_id` int(10) NOT NULL auto_increment,`vht_id` int(10) NOT NULL DEFAULT 0,`cr_id` int(10) NOT NULL DEFAULT 0,`crvh_id_inv` int(10) NOT NULL DEFAULT 0,`crvh_sort` smallint(4) NOT NULL DEFAULT 0,`crvh_sid` varchar(10) NOT NULL DEFAULT '',`crvh_comm_no` varchar(10) NOT NULL DEFAULT '',`crvh_vh_sign` varchar(10) NULL DEFAULT NULL,`crvh_vh_name` varchar(30) NULL DEFAULT NULL,`crvh_licence_date` date NULL DEFAULT NULL,`crvh_owner` date NULL DEFAULT NULL,`crvh_env_badge_level` tinyint(2) NOT NULL DEFAULT 0,`crvh_prov` float(5,2) NOT NULL DEFAULT 0.00,`crvh_payload` int(10) NOT NULL DEFAULT 0,`crvh_totalweight` int(10) NOT NULL DEFAULT 0,`crvh_length` int(10) NOT NULL DEFAULT 0,`crvh_width` int(10) NOT NULL DEFAULT 0,`crvh_height` int(10) NOT NULL DEFAULT 0,`crvh_position` int(10) NOT NULL DEFAULT 0,`crvh_aperture_height_side` int(10) NOT NULL DEFAULT 0,`crvh_aperture_height_rear` int(10) NOT NULL DEFAULT 0,`crvh_mobile` varchar(20) NULL DEFAULT NULL,`crvh_insurance_name` varchar(30) NULL DEFAULT NULL,`crvh_insurance_no` varchar(30) NULL DEFAULT NULL,`crvh_remark` varchar(30) NULL DEFAULT NULL,`crvh_filter` text NOT NULL,`crvh_service` varchar(255) NOT NULL,`crvh_group` varchar(255) NULL DEFAULT NULL,`crvh_special` tinyint(1) NOT NULL DEFAULT 0,`crvh_vhd_disabled` tinyint(1) NOT NULL DEFAULT 0,`crvh_corporate_identity` tinyint(4) NOT NULL DEFAULT 0,`crvh_funding_state` tinyint(1) NOT NULL,`crvh_funding_final_installment` date NULL DEFAULT NULL,`crvh_funding_residual_value` float(8,2) NOT NULL,`crvh_pda_usage` tinyint(1) NOT NULL DEFAULT 0,`crvh_system_fee` tinyint(1) NOT NULL DEFAULT 0,`crvh_drivetype` tinyint(2) NOT NULL DEFAULT 0,`crvh_partner_premium` float(7,2) NOT NULL DEFAULT 0.00,`crvh_partner_text` text NOT NULL,`crvh_partner_commission` float(5,2) NOT NULL DEFAULT 0.00,`crvh_advertising_allowance` float(5,2) NOT NULL DEFAULT 0.00,`crvh_transport_insurance_amount` float(7,2) NOT NULL DEFAULT 0.00,`crvh_transport_insurance_date` date NOT NULL,`crvh_business_liability_insurance_amount` float(7,2) NOT NULL DEFAULT 0.00,`crvh_business_liability_insurance_date` date NOT NULL,`crvh_system_fee_amount` float(7,2) NOT NULL DEFAULT 0.00,`crvh_freetext_1` text NOT NULL,`crvh_modify_status` int(1) NOT NULL DEFAULT 0,`crvh_modify` timestamp NOT NULL DEFAULT current_timestamp() on update current_timestamp(),`crvh_export_time` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',PRIMARY KEY (`crvh_id`)) ENGINE=InnoDB;
DROP TABLE IF EXISTS autoranking;
CREATE TABLE autoranking (`jb_id` int(10) NOT NULL DEFAULT 0,`cr_id` int(10) NOT NULL DEFAULT 0,`ar_challenge` smallint(2) NOT NULL DEFAULT 0,`ar_lastassigntime` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',`ar_looseranking` smallint(1) NOT NULL DEFAULT 0,`ar_locating` smallint(1) NOT NULL DEFAULT 0,`ar_cr_order` smallint(1) NOT NULL DEFAULT 0) ENGINE=InnoDB;
DROP TABLE IF EXISTS vehicledisposition;
CREATE TABLE vehicledisposition (`crvh_id` int(10) NOT NULL DEFAULT 0,`vhd_timeslot` varchar(20) NOT NULL DEFAULT '',`jb_id` int(10) NOT NULL DEFAULT 0,PRIMARY KEY (`crvh_id`,`vhd_timeslot`)) ENGINE=InnoDB;
DROP TABLE IF EXISTS servicepricehistory;
CREATE TABLE servicepricehistory (`srvprch_obj_type` varchar(10) NOT NULL,`srvprch_obj_id` int(10) NOT NULL,`srvprch_obj_eid` varchar(20) NOT NULL,`srvprch_fieldname` varchar(50) NOT NULL,`srvprch_context` varchar(32) NOT NULL,`srvprch_price` float(10,4) NOT NULL DEFAULT 0.0000,`srvprch_validfrom` datetime NOT NULL) ENGINE=InnoDB;
DROP TABLE IF EXISTS invoice;
CREATE TABLE invoice (`jb_id` int(10) NOT NULL DEFAULT 0,`inv_no` varchar(30) NOT NULL DEFAULT '',`inv_datetime` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',PRIMARY KEY (`jb_id`)) ENGINE=MyISAM;
DROP TABLE IF EXISTS address;
CREATE TABLE address (`ad_id` int(10) NOT NULL auto_increment,`ad_street` varchar(50) NOT NULL DEFAULT '',`ad_zipcode` varchar(10) NOT NULL DEFAULT '',`ad_city` varchar(50) NOT NULL DEFAULT '',`ad_country` varchar(10) NULL DEFAULT NULL,`ad_modify` timestamp NOT NULL DEFAULT current_timestamp() on update current_timestamp(),PRIMARY KEY (`ad_id`)) ENGINE=InnoDB;
CREATE DATABASE IF NOT EXISTS phoenix_pda;
DROP TABLE IF EXISTS phoenix_pda.commandexec;
CREATE TABLE phoenix_pda.commandexec (`cmde_id` int(6) NOT NULL auto_increment,`cr_id` int(8) NOT NULL DEFAULT 0,`hq_id` int(4) NOT NULL DEFAULT 0,`cmd_command` varchar(5) NOT NULL DEFAULT '0',`cmde_exec_time` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',`cmde_exec_timelimit` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',`cmde_mode` varchar(10) NOT NULL DEFAULT '',`cmde_param` text NULL DEFAULT NULL,PRIMARY KEY (`cmde_id`)) ENGINE=InnoDB;
DROP TABLE IF EXISTS customerservice;
CREATE TABLE customerservice (`cs_id` int(10) NOT NULL DEFAULT 0,`srv_id` int(10) NOT NULL DEFAULT 0,`cssrv_sort` smallint(3) NOT NULL DEFAULT 0) ENGINE=InnoDB;
DROP TABLE IF EXISTS headquarters;
CREATE TABLE headquarters(hq_id INT PRIMARY KEY) ENGINE=InnoDB;
INSERT INTO headquarters VALUES (7),(8);
DROP TABLE IF EXISTS genericdatacontainer;
CREATE TABLE genericdatacontainer (gdc_id INT AUTO_INCREMENT PRIMARY KEY,gdc_obj_type VARCHAR(10),gdc_obj_id INT,gdc_gen_fieldname VARCHAR(50),gdc_content MEDIUMTEXT,gdc_context TEXT) ENGINE=InnoDB;
DROP TABLE IF EXISTS metatype;
CREATE TABLE metatype (mt_id INT AUTO_INCREMENT PRIMARY KEY,mt_type VARCHAR(50),mt_sort INT,mt_value VARCHAR(50),mt_mapped_value VARCHAR(50)) ENGINE=InnoDB;
INSERT INTO metatype(mt_type,mt_sort,mt_value,mt_mapped_value) VALUES ('vehicletype',1,'PKW','PKW'),('payment_term',0,'0','10 Tage netto');
DROP TABLE IF EXISTS parameter;
CREATE TABLE parameter (par_id INT AUTO_INCREMENT PRIMARY KEY,hq_id INT,emp_id INT,par_key VARCHAR(100),par_value TEXT) ENGINE=InnoDB;
DROP TABLE IF EXISTS tourarticle;
CREATE TABLE tourarticle(trat_id INT AUTO_INCREMENT PRIMARY KEY,jb_id INT) ENGINE=InnoDB;
DROP TABLE IF EXISTS jobprice;
CREATE TABLE jobprice(jb_id INT NOT NULL,mt_sort SMALLINT NOT NULL,jbprc_price FLOAT(10,4) NOT NULL DEFAULT 0,jbprc_remark VARCHAR(50) NOT NULL DEFAULT '',jbprc_reference INT NOT NULL DEFAULT 0) ENGINE=InnoDB;
CREATE DATABASE IF NOT EXISTS phoenix_log;
DROP TABLE IF EXISTS phoenix_log.log;
CREATE TABLE phoenix_log.log(log_id INT AUTO_INCREMENT PRIMARY KEY,logo_id INT,hq_id INT,jb_id INT,usr_id INT,cr_id INT,cr_sid VARCHAR(10),cs_id INT,at_id INT,pt_id INT,emp_id INT,logo_description TEXT,log_createtime DATETIME) ENGINE=MyISAM;
DROP TABLE IF EXISTS tax;
CREATE TABLE tax(tx_id INT PRIMARY KEY,tx_sign VARCHAR(20),tx_value INT) ENGINE=InnoDB;
INSERT INTO tax VALUES(0,'USt',19);
DROP TABLE IF EXISTS service;
CREATE TABLE service(srv_id INT PRIMARY KEY,hq_id INT,cs_id INT,srv_name VARCHAR(100),srv_mode INT) ENGINE=InnoDB;
DROP TABLE IF EXISTS servicetype;
CREATE TABLE servicetype(srvt_id INT PRIMARY KEY,hq_id INT,cs_id INT,srvt_name VARCHAR(100),srvt_mode INT) ENGINE=InnoDB;
DROP TABLE IF EXISTS servicehistory;
CREATE TABLE servicehistory(srv_id INT,srvt_id INT,hq_id INT,cs_id INT,srvh_price DECIMAL(10,2),srvh_cr_price DECIMAL(10,2),srvh_discount DECIMAL(10,2),srvh_validfrom DATETIME) ENGINE=InnoDB;
DROP TABLE IF EXISTS customerservicetype;
CREATE TABLE customerservicetype(cs_id INT,srvt_id INT) ENGINE=InnoDB;
INSERT INTO tax VALUES(1,'USt',19);
@@ -0,0 +1,14 @@
DROP TABLE IF EXISTS tourarticle;
CREATE TABLE tourarticle (`trat_id` int(10) NOT NULL auto_increment,`tr_id` int(10) NOT NULL DEFAULT 0,`jb_id` int(10) NOT NULL DEFAULT 0,`tr_sort` int(3) NOT NULL DEFAULT 0,`trat_sort` smallint(5) NOT NULL DEFAULT 0,`at_id` int(10) NULL DEFAULT NULL,`trat_name` varchar(30) NOT NULL,`trat_quantity` smallint(5) NOT NULL DEFAULT 0,`trat_weight` decimal(9,3) NULL DEFAULT 0.000,`trat_price` decimal(8,2) NOT NULL DEFAULT 0.00,`trat_packingpieces` tinyint(3) NOT NULL,`trat_int_serialno` varchar(255) NOT NULL,`trat_serialno` varchar(255) NOT NULL DEFAULT '',`trat_state` tinyint(2) NOT NULL DEFAULT 0,`trat_remark` text NULL DEFAULT NULL,`trat_createtime` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',`trat_modify` timestamp NOT NULL DEFAULT current_timestamp() on update current_timestamp(),PRIMARY KEY (`trat_id`)) ENGINE=InnoDB;
DROP TABLE IF EXISTS tourarticleprocess;
CREATE TABLE tourarticleprocess (`tratp_id` int(10) NOT NULL auto_increment,`tr_id` int(10) NOT NULL DEFAULT 0,`trat_id` int(10) NOT NULL DEFAULT 0,`tratp_no` varchar(20) NOT NULL,`tratp_event` smallint(5) NOT NULL DEFAULT 0,`tratp_type` smallint(4) NOT NULL DEFAULT 0,`tratp_state` tinyint(2) NOT NULL DEFAULT 0,`tratp_quantity` smallint(5) NOT NULL DEFAULT 0,`tratp_packingpieces` smallint(5) NOT NULL DEFAULT 0,`tratp_gps_long` float(9,6) NOT NULL DEFAULT 0.000000,`tratp_gps_lat` float(9,6) NOT NULL DEFAULT 0.000000,`tratp_remark` text NULL DEFAULT NULL,`tratp_createtime` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',PRIMARY KEY (`tratp_id`)) ENGINE=InnoDB;
DROP TABLE IF EXISTS article;
CREATE TABLE article (`at_id` int(10) NOT NULL auto_increment,`hq_id` int(10) NOT NULL DEFAULT 0,`at_eid` varchar(20) NULL DEFAULT NULL,`at_name` varchar(50) NOT NULL DEFAULT '',`atg_id` int(10) NULL DEFAULT NULL,`at_match` varchar(100) NOT NULL DEFAULT '',`at_description` text NOT NULL,`at_barcode` varchar(50) NOT NULL DEFAULT '',`at_authenticated` tinyint(1) NOT NULL DEFAULT 1,`at_visible` tinyint(1) NOT NULL DEFAULT 1,`at_modify_status` tinyint(1) NOT NULL DEFAULT 0,`at_createtime` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',`at_group` varchar(255) NULL DEFAULT NULL,`at_bundlequantity` smallint(6) NOT NULL DEFAULT 1,`at_bundlecode` varchar(50) NULL DEFAULT NULL,`at_stk_itemquantity` mediumint(9) NOT NULL DEFAULT 0,`at_stk_areaquantity` tinyint(4) NOT NULL DEFAULT 0,`at_serialno` tinyint(4) NOT NULL DEFAULT 0,`at_totalweight` float(10,4) NOT NULL DEFAULT 0.0000,`at_packingpieces` tinyint(3) NOT NULL DEFAULT 1,`at_timeunits` tinyint(3) NOT NULL DEFAULT 0,`at_mountable` tinyint(1) NOT NULL,`at_modify` timestamp NOT NULL DEFAULT current_timestamp() on update current_timestamp(),PRIMARY KEY (`at_id`)) ENGINE=InnoDB;
DROP TABLE IF EXISTS stock;
CREATE TABLE stock (`stk_id` int(10) NOT NULL auto_increment,`hq_id` int(10) NOT NULL DEFAULT 0,`stk_pre_id` int(10) NOT NULL DEFAULT 0,`stk_obj_type` varchar(10) NOT NULL,`stk_obj_id` int(10) NOT NULL DEFAULT 0,`stk_name` varchar(30) NOT NULL DEFAULT '',`stk_path` varchar(255) NOT NULL DEFAULT '',`stk_barcode` varchar(50) NOT NULL DEFAULT '',`ad_id` int(10) NOT NULL DEFAULT 0,`stk_hsno` varchar(10) NOT NULL DEFAULT '',`cs_id` int(10) NOT NULL DEFAULT 0,`csc_id` int(10) NULL DEFAULT NULL,`stk_maxquantity` int(10) NOT NULL DEFAULT 0,`stk_planning` tinyint(1) NOT NULL DEFAULT 1,`stk_visible` tinyint(1) NOT NULL DEFAULT 0,`stk_readonly` tinyint(1) NOT NULL DEFAULT 0,`stk_modifytime` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',PRIMARY KEY (`stk_id`)) ENGINE=InnoDB;
DROP TABLE IF EXISTS stockarticle;
CREATE TABLE stockarticle (`stk_id` int(10) NOT NULL DEFAULT 0,`at_id` int(10) NOT NULL DEFAULT 0,`stkat_maxquantity` int(10) NOT NULL DEFAULT 0,`stkat_quantity` int(10) NOT NULL DEFAULT 0,`stkat_itemquantity` int(10) NOT NULL DEFAULT 0,`stkat_modifytime` datetime NOT NULL DEFAULT '0000-00-00 00:00:00') ENGINE=InnoDB;
DROP TABLE IF EXISTS articleitem;
CREATE TABLE articleitem (`ati_id` int(10) NOT NULL auto_increment,`at_id` int(10) NOT NULL DEFAULT 0,`stk_id` int(10) NOT NULL DEFAULT 0,`ati_serialno` varchar(255) NOT NULL DEFAULT '',`ati_data_01` varchar(50) NOT NULL DEFAULT '',`ati_data_02` varchar(50) NOT NULL DEFAULT '',`ati_data_03` varchar(50) NOT NULL DEFAULT '',`ati_data_04` varchar(50) NOT NULL DEFAULT '',`ati_data_05` varchar(50) NOT NULL DEFAULT '',`ati_data_06` varchar(50) NOT NULL DEFAULT '',`ati_data_07` varchar(50) NOT NULL DEFAULT '',`ati_data_08` varchar(50) NOT NULL DEFAULT '',`ati_data_09` varchar(50) NOT NULL DEFAULT '',`ati_data_10` varchar(50) NOT NULL DEFAULT '',`ati_data_11` varchar(50) NOT NULL DEFAULT '',`ati_data_12` varchar(50) NOT NULL DEFAULT '',`ati_data_13` varchar(50) NOT NULL DEFAULT '',`ati_data_14` varchar(50) NOT NULL DEFAULT '',`ati_data_15` varchar(50) NOT NULL DEFAULT '',`ati_data_16` varchar(50) NOT NULL DEFAULT '',`ati_data_17` varchar(50) NOT NULL DEFAULT '',`ati_data_18` varchar(50) NOT NULL DEFAULT '',`ati_data_19` varchar(50) NOT NULL DEFAULT '',`ati_data_20` varchar(50) NOT NULL DEFAULT '',`ati_data_21` varchar(50) NOT NULL DEFAULT '',`ati_data_22` varchar(50) NOT NULL DEFAULT '',`ati_data_23` varchar(50) NOT NULL DEFAULT '',`ati_data_24` varchar(50) NOT NULL DEFAULT '',`ati_data_25` varchar(50) NOT NULL DEFAULT '',PRIMARY KEY (`ati_id`)) ENGINE=InnoDB;
DROP TABLE IF EXISTS stockmove;
CREATE TABLE stockmove (`stkmv_id` int(10) NOT NULL auto_increment,`stk_id_from` int(10) NULL DEFAULT NULL,`stk_id_to` int(10) NOT NULL DEFAULT 0,`at_id` int(10) NOT NULL DEFAULT 0,`stkmv_quantity` int(10) NULL DEFAULT 0,`stkmv_itemquantity` int(11) NOT NULL DEFAULT 0,`stkmv_bundlequantity` smallint(6) NOT NULL DEFAULT 1,`stkmv_bundlecode` varchar(50) NOT NULL DEFAULT '',`stkmv_tan` varchar(20) NOT NULL DEFAULT '',`stkmv_remark` varchar(255) NOT NULL DEFAULT '',`stkmv_serialno` varchar(50) NOT NULL DEFAULT '',`stkmv_data` text NOT NULL,`at_id_bundle` int(11) NOT NULL DEFAULT 0,`stkmv_datetime` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',`stkmv_datetime2` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',`usr_id` int(10) NOT NULL DEFAULT 0,`jb_id` int(10) NOT NULL DEFAULT 0,`stkmv_timestamp` datetime NULL DEFAULT NULL,PRIMARY KEY (`stkmv_id`)) ENGINE=InnoDB;