Binde Auftragshistorie an und korrigiere Kundenanzeige

Ergänzt die lesende Historien-API mit Verlaufsanzeige und ordnet Auftragskunden dem effektiven Zahler zu. Enthält VS-Code-Debugkonfiguration, Dokumentation und Regressionstests.

Validiert mit Frontend-Build, 76 Backend-Tests und 25 Browsertests. Live-Datenbankprüfung wegen fehlender Erreichbarkeit offen.
This commit is contained in:
Sven committed 2026-10-07 20:40:58 +02:00
1 parent ac5ea374b9
commit 75e2bf2bec
24 files changed
+1069 -60

No files matched your search

@@ -17,6 +17,7 @@ import org.springframework.test.web.servlet.MvcResult;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
@@ -35,6 +36,7 @@ class ApiSecurityTest {
record Csrf(MockHttpSession session, String token, String header) {}
@BeforeEach void configure() {
when(database.readTransaction(any())).thenAnswer(call -> ((Supplier<?>)call.getArgument(0)).get());
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(true,"CONNECTED","Verbunden"));
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","111111111111")));
@@ -70,6 +72,8 @@ class ApiSecurityTest {
MvcResult unauthorized = mvc.perform(get("/api/orders")).andExpect(status().isUnauthorized())
.andExpect(header().doesNotExist("WWW-Authenticate")).andReturn();
assertThat(unauthorized.getRequest().getSession(false)).isNull();
mvc.perform(get("/api/orders/42/history")).andExpect(status().isUnauthorized())
.andExpect(header().doesNotExist("WWW-Authenticate"));
Csrf csrf = csrf(null);
mvc.perform(post("/api/orders").session(csrf.session()).header(csrf.header(),csrf.token())
.contentType("application/json").content("{}"))
@@ -180,6 +184,52 @@ class ApiSecurityTest {
mvc.perform(get("/api/orders").session(session)).andExpect(status().isForbidden());
mvc.perform(get("/api/customers").session(session)).andExpect(status().isForbidden());
mvc.perform(get("/api/orders/export.csv").session(session)).andExpect(status().isForbidden());
mvc.perform(get("/api/orders/42/history").session(session)).andExpect(status().isForbidden());
verify(database,never()).queryForList(contains("phoenix_log"),any(Object[].class));
}
@Test void historyOfMissingOrForeignOrderReturns404WithoutReadingLog() throws Exception {
MockHttpSession session = authenticated("existing-user","existing-password");
mvc.perform(get("/api/orders/42/history").session(session)).andExpect(status().isNotFound());
verify(database).queryForList("SELECT jb.jb_id FROM job jb WHERE jb.jb_id=? AND jb.hq_id=?",42L,7L);
verify(database,never()).queryForList(contains("phoenix_log"),any(Object[].class));
}
@Test void accessibleOrderWithNoLogReturnsEmptyHistory() throws Exception {
MockHttpSession session = authenticated("existing-user","existing-password");
when(database.queryForList("SELECT jb.jb_id FROM job jb WHERE jb.jb_id=? AND jb.hq_id=?",42L,7L))
.thenReturn(List.of(Map.of("jb_id",42L)));
mvc.perform(get("/api/orders/42/history").session(session))
.andExpect(status().isOk()).andExpect(content().json("[]"));
verify(database).queryForList(contains("FROM `phoenix_log`.log l"),eq(42L),eq(7L));
}
@Test void historyReturnsExistingEvents() throws Exception {
MockHttpSession session = authenticated("existing-user","existing-password");
when(database.queryForList("SELECT jb.jb_id FROM job jb WHERE jb.jb_id=? AND jb.hq_id=?",42L,7L))
.thenReturn(List.of(Map.of("jb_id",42L)));
when(database.queryForList(contains("FROM `phoenix_log`.log l"),eq(42L),eq(7L)))
.thenReturn(List.of(Map.of("log_id",81L,"logo_id",1,"log_createtime","2026-10-07 08:15:00",
"logo_op_name","Auftrag angelegt","logo_description","Testhistorie","usr_account","disponent")));
mvc.perform(get("/api/orders/42/history").session(session)).andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1)).andExpect(jsonPath("$[0].id").value(81))
.andExpect(jsonPath("$[0].at").value("2026-10-07T08:15:00"))
.andExpect(jsonPath("$[0].action").isString()).andExpect(jsonPath("$[0].actor").isString());
}
@Test void logFailureReturns503AndDoesNotDiscloseDatabaseDetails() throws Exception {
MockHttpSession session = authenticated("existing-user","existing-password");
when(database.queryForList("SELECT jb.jb_id FROM job jb WHERE jb.jb_id=? AND jb.hq_id=?",42L,7L))
.thenReturn(List.of(Map.of("jb_id",42L)));
when(database.queryForList(contains("FROM `phoenix_log`.log l"),eq(42L),eq(7L)))
.thenThrow(new DataAccessResourceFailureException("private database credentials"));
var response=mvc.perform(get("/api/orders/42/history").session(session))
.andExpect(status().isServiceUnavailable()).andReturn();
String message=json.readTree(response.getResponse().getContentAsString()).get("message").asText();
assertThat(message).contains("Auftragshistorie").doesNotContain("private database credentials");
}
@Test void databaseAccountDenialIsDistinguishedFromNetworkFailure() throws Exception {
@@ -24,6 +24,7 @@ import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
@@ -42,6 +43,8 @@ class DatabaseAccessArchitectureTest {
);
// Timestamp is a value already returned by JDBC, not a connection or execution API.
private static final Set<String> ALLOWED_VALUE_TYPES = Set.of("java/sql/Timestamp");
// String constants may be JSON or regexes. Only actual JVM array type names are descriptors.
private static final Pattern ARRAY_DESCRIPTOR = Pattern.compile("\\[{1,255}(?:[BCDFIJSZ]|L[^.;\\[/]+(?:/[^.;\\[/]+)*;)");
@Test void productionDatabaseAccessGoesThroughOneService() throws Exception {
assertNoBypasses(classesDirectory(VotianApplication.class), false);
@@ -82,6 +85,32 @@ class DatabaseAccessArchitectureTest {
assertThat(dependencies(writer.toByteArray())).containsExactly("java/sql/Connection");
}
@Test void arrayLikeTextIsIgnoredButReflectiveJdbcArrayTypesAreDetected() {
ClassWriter writer = new ClassWriter(0);
writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "ArrayNames", null, "java/lang/Object", null);
MethodVisitor method = writer.visitMethod(Opcodes.ACC_PUBLIC, "read", "()V", null, null);
method.visitCode();
for (String text : List.of("[A-Za-z0-9_]{1,64}", "[]", "[", "[V", "[I trailing text",
"[Ljava/sql/Connection; trailing text", "[I", "[[Z", "[[Ljava.lang.String;")) {
method.visitLdcInsn(text);
method.visitInsn(Opcodes.POP);
}
method.visitInsn(Opcodes.RETURN);
method.visitMaxs(1, 1);
method.visitEnd();
writer.visitEnd();
assertThat(dependencies(writer.toByteArray())).isEmpty();
writer = new ClassWriter(0);
writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "ReflectiveArrayBypass", null, "java/lang/Object", null);
writer.visitField(Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_FINAL,
"connectionArray", "Ljava/lang/String;", null, "[Ljava.sql.Connection;").visitEnd();
writer.visitField(Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_FINAL,
"dataSourceArrays", "Ljava/lang/String;", null, "[[Ljavax/sql/DataSource;").visitEnd();
writer.visitEnd();
assertThat(dependencies(writer.toByteArray())).containsExactly("java/sql/Connection", "javax/sql/DataSource");
}
@Test void pythonLaunchersDoNotOpenTheirOwnDatabaseConnections() throws Exception {
Path scripts = projectRoot().resolve("scripts");
assertThat(scripts).isDirectory();
@@ -168,7 +197,7 @@ class DatabaseAccessArchitectureTest {
new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) {
void name(String name) {
if (name == null) return;
if (name.startsWith("[")) { descriptor(name); return; }
if (ARRAY_DESCRIPTOR.matcher(name).matches()) { descriptor(name); return; }
if (!ALLOWED_VALUE_TYPES.contains(name) && FORBIDDEN_PREFIXES.stream().anyMatch(name::startsWith)) forbidden.add(name);
}
void type(Type type) {
@@ -1,26 +1,77 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Objects;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeFalse;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/** Opt-in SELECT-only check against the configured existing database. No accounts or data are created. */
@EnabledIfEnvironmentVariable(named="VOTIAN_VERIFY_EXISTING_DB", matches="true")
@SpringBootTest
@SpringBootTest(properties={"debug=false","logging.level.org.springframework=INFO"})
@AutoConfigureMockMvc(printOnlyOnFailure=false, print=org.springframework.boot.test.autoconfigure.web.servlet.MockMvcPrint.NONE)
class LegacyDatabaseReadIntegrationTest {
@Autowired DatabaseService database;
@Autowired MockMvc mvc;
@Autowired ObjectMapper json;
@Value("${app.history-schema:phoenix_log}") String historySchema;
@Test void latestOrderCustomerProjectionMatchesIndependentLegacyRelations() {
database.readTransaction(() -> {
// Compare the production join with independent scalar lookups. LIMIT applies to
// source jobs here, so an accidental multiplicative join cannot hide missing jobs.
var expected=database.queryForList("""
SELECT expected.*, expected_company.cmp_comp AS expected_customer_name
FROM (
SELECT jb.jb_id,jb.jb_ordertime,
CASE WHEN jb.jb_payment=2 AND jb.csc_id_payer_cash>0
THEN (SELECT cash.cs_id FROM costcenter cash WHERE cash.csc_id=jb.csc_id_payer_cash)
ELSE (SELECT invoice.cs_id FROM costcenter invoice WHERE invoice.csc_id=jb.csc_id_payer AND invoice.csc_id>0)
END AS expected_customer_id,
(SELECT payer.cs_id FROM costcenter payer WHERE payer.csc_id=jb.csc_id_payer) AS expected_payer_customer_id,
(SELECT orderer.cs_id FROM costcenter orderer WHERE orderer.csc_id=jb.csc_id) AS expected_orderer_customer_id
FROM job jb ORDER BY jb.jb_ordertime DESC,jb.jb_id DESC LIMIT 100
) expected
LEFT JOIN customer expected_customer ON expected_customer.cs_id=expected.expected_customer_id
LEFT JOIN company expected_company ON expected_company.cmp_id=expected_customer.cmp_id
ORDER BY expected.jb_ordertime DESC,expected.jb_id DESC
""");
assumeFalse(expected.isEmpty(),"Keine vorhandenen Aufträge für die lesende Relationsprüfung");
var actual=database.queryForList(LegacyReadService.ORDER_SELECT+LegacyReadService.ORDER_FROM
+" ORDER BY jb.jb_ordertime DESC,jb.jb_id DESC LIMIT 100")
.stream().map(LegacyMapping::order).toList();
// Boolean equality assertions intentionally keep live customer data out of diffs.
assertThat(actual.size()).as("Anzahl projizierter Aufträge").isEqualTo(expected.size());
for(int index=0;index<expected.size();index++) {
var source=expected.get(index);
var order=actual.get(index);
assertThat(Objects.equals(order.id,LegacyMapping.id(source,"jb_id")))
.as("Auftragsreihenfolge stimmt überein").isTrue();
assertThat(Objects.equals(order.customerId,LegacyMapping.nullableId(source,"expected_customer_id")))
.as("Kunde folgt dem Rechnungszahler bzw. tatsächlichen Barzahler").isTrue();
assertThat(Objects.equals(order.customerName,LegacyMapping.text(source,"expected_customer_name")))
.as("Kundenname stammt aus der Firma des zugeordneten Kunden").isTrue();
assertThat(Objects.equals(order.payerCustomerId,LegacyMapping.nullableId(source,"expected_payer_customer_id")))
.as("Rechnungszahler bleibt separat erhalten").isTrue();
assertThat(Objects.equals(order.ordererCustomerId,LegacyMapping.nullableId(source,"expected_orderer_customer_id")))
.as("Besteller bleibt separat erhalten").isTrue();
}
return null;
});
}
@Test void readsExistingListsThroughHttpWithNumericLegacyFlags() throws Exception {
// The session default is not the active transaction's access mode. Enforced READ ONLY
@@ -40,4 +91,37 @@ class LegacyDatabaseReadIntegrationTest {
Object archived=database.queryForObject("SELECT cmp_archived FROM company WHERE cmp_archived IS NOT NULL LIMIT 1",Object.class);
assertThat(archived).isInstanceOf(Number.class);
}
@Test void readsConfiguredLogSchemaAndExistingOrderHistoryThroughHttp() throws Exception {
assertThat(historySchema.matches("[A-Za-z0-9_]{1,64}")).isTrue();
String logTable="`"+historySchema+"`.log";
// Only identifiers are selected for the fixture; no account or business data is logged.
var candidates=database.queryForList("SELECT recent.jb_id,recent.hq_id FROM "
+"(SELECT jb_id,hq_id FROM job ORDER BY jb_ordertime DESC,jb_id DESC LIMIT 100) recent JOIN "
+logTable+" l ON l.jb_id=recent.jb_id LIMIT 1");
assertThat(candidates.size()).as("Mindestens einer der letzten 100 Aufträge hat eine Historie").isEqualTo(1);
long orderId=((Number)candidates.getFirst().get("jb_id")).longValue();
long headquartersId=((Number)candidates.getFirst().get("hq_id")).longValue();
var principal=new LegacyUser(0,0,headquartersId,"read-only-integration-test","000010");
var auth=UsernamePasswordAuthenticationToken.authenticated(principal,null,List.of());
var response=mvc.perform(get("/api/orders/{id}/history",orderId).with(authentication(auth)))
.andExpect(status().isOk()).andReturn();
var history=json.readTree(response.getResponse().getContentAsString());
Long count=database.queryForObject("SELECT COUNT(*) FROM "+logTable
+" l JOIN job jb ON jb.jb_id=l.jb_id WHERE jb.jb_id=? AND jb.hq_id=?",Long.class,orderId,headquartersId);
// Assert only counts and JSON types. Assertion diffs must never contain live history text.
assertThat(history.isArray()).isTrue();
assertThat(count).isPositive();
assertThat((long)history.size()).isEqualTo(count);
for(var event:history) {
assertThat(event.isObject()).isTrue();
assertThat(event.has("id") && event.get("id").isIntegralNumber()).isTrue();
assertThat(event.has("at") && (event.get("at").isTextual() || event.get("at").isNull())).isTrue();
assertThat(event.has("action") && event.get("action").isTextual()).isTrue();
assertThat(event.has("actor") && event.get("actor").isTextual()).isTrue();
assertThat(event.has("detail") && (event.get("detail").isTextual() || event.get("detail").isNull())).isTrue();
}
}
}
@@ -0,0 +1,81 @@
package de.stadtbote.votian;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class LegacyHistoryMappingTest {
@Test void mapsStoredEventWithUserAndVehicle() {
var row = new HashMap<String, Object>(Map.of(
"log_id", 31L, "log_createtime", Timestamp.valueOf("2026-10-07 11:32:54"),
"logo_id", 5, "logo_op_shortname", "Auftrag&nbsp;zugeordnet",
"usr_firstname", "Erika", "usr_name", "Muster", "cr_sid", "A-007",
"logo_description", "REMARK_TEXT=Empfang im 2. OG"));
var history = LegacyHistoryMapping.history(row);
assertThat(history.id).isEqualTo(31L);
assertThat(history.at).isEqualTo(LocalDateTime.of(2026, 10, 7, 11, 32, 54));
assertThat(history.action).isEqualTo("Auftrag zugeordnet");
assertThat(history.actor).isEqualTo("Erika Muster");
assertThat(history.detail).isEqualTo("Fahrzeug: A-007\nBemerkung: Empfang im 2. OG");
}
@Test void preservesValuesWithEqualsAndUnknownKeysWithoutInferringAmounts() {
String raw = "|$jb_totalprice_old=119.00|$jb_totalprice=125.50|FUTURE_KEY=a=b=c|EMPTY=|";
assertThat(LegacyHistoryMapping.description(raw)).isEqualTo(
"Auftragspreis (bisher): 119.00\nAuftragspreis: 125.50\nFUTURE_KEY: a=b=c\nEMPTY:");
}
@Test void handlesMixedFreeTextAndLegacyStationChanges() {
assertThat(LegacyHistoryMapping.description(
"Nacherfassung|$tr_remark_old_2=Beim Pförtner|$tr_remark_2=Am Empfang|COSTS=YES"))
.isEqualTo("Nacherfassung\nBemerkungen (bisher) (Station 2): Beim Pförtner\n"
+ "Bemerkungen (Station 2): Am Empfang\nKostenpflichtig: YES");
assertThat(LegacyHistoryMapping.description("2 < 5 und 7 > 3")).isEqualTo("2 < 5 und 7 > 3");
assertThat(LegacyHistoryMapping.description("Kontakt <kunde@example.test> oder <Unbekannt>"))
.isEqualTo("Kontakt <kunde@example.test> oder <Unbekannt>");
assertThat(LegacyHistoryMapping.description("Kontakt <p@example.test>"))
.isEqualTo("Kontakt <p@example.test>");
}
@Test void projectsLegacyMarkupAsTextAndDoesNotGenerateHtml() {
assertThat(LegacyHistoryMapping.description("REMARK_TEXT=<b>Tür</b>&nbsp;links<br>2.&nbsp;OG &amp; Hof"))
.isEqualTo("Bemerkung: Tür links\n2. OG & Hof");
assertThat(LegacyHistoryMapping.description("REMARK_TEXT=<img src=x onerror=alert(1)>Empfang"))
.isEqualTo("Bemerkung: Empfang");
// Entity-encoded markup remains a literal text value, never executable HTML.
assertThat(LegacyHistoryMapping.description("REMARK_TEXT=&lt;script&gt;Text&lt;/script&gt;"))
.isEqualTo("Bemerkung: <script>Text</script>");
}
@Test void keepsActorAndOperationFallbacksHonest() {
var row = new HashMap<String, Object>(Map.of(
"log_id", 2, "logo_id", 79, "usr_id", 45,
"logo_op_name", "Auftrag geändert", "usr_account", "dispatch"));
assertThat(LegacyHistoryMapping.history(row).action).isEqualTo("Auftrag geändert");
assertThat(LegacyHistoryMapping.history(row).actor).isEqualTo("dispatch");
row.remove("usr_account");
row.remove("logo_op_name");
assertThat(LegacyHistoryMapping.history(row).actor).isEqualTo("Benutzer 45");
assertThat(LegacyHistoryMapping.history(row).action).isEqualTo("Vorgang 79");
row.remove("usr_id");
row.remove("logo_id");
assertThat(LegacyHistoryMapping.history(row).actor).isEqualTo("Nicht hinterlegt");
assertThat(LegacyHistoryMapping.history(row).action).isEqualTo("Unbekannter Vorgang");
}
@Test void supportsLegacyTimestampFormsAndMissingValues() {
assertThat(LegacyHistoryMapping.history(Map.of("LOG_CREATETIME", "20261007113254")).at)
.isEqualTo(LocalDateTime.of(2026, 10, 7, 11, 32, 54));
assertThat(LegacyHistoryMapping.history(Map.of("log_createtime", "0000-00-00 00:00:00")).at).isNull();
var history = LegacyHistoryMapping.history(Map.of());
assertThat(history.at).isNull();
assertThat(history.detail).isEmpty();
assertThat(LegacyHistoryMapping.description(null)).isEmpty();
}
}
@@ -0,0 +1,106 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class LegacyHistoryServiceTest {
final DatabaseService database=mock(DatabaseService.class);
final LegacyHistoryService service=new LegacyHistoryService(database,"phoenix_log");
final LegacyUser allowed=new LegacyUser(12,13,7,"disponent","000010");
@BeforeEach void configureTransactions() {
when(database.readTransaction(any())).thenAnswer(call -> ((Supplier<?>)call.getArgument(0)).get());
}
@Test void requiresOrderRightBeforeAnyDatabaseAccess() {
var denied=new LegacyUser(12,13,7,"restricted","111101");
assertThatThrownBy(()->service.history(denied,42)).isInstanceOf(AccessDeniedException.class);
verifyNoInteractions(database);
}
@Test void missingOrForeignOrderReturns404BeforeReadingLogSchema() {
when(database.queryForList(anyString(),any(Object[].class))).thenReturn(List.of());
assertThatThrownBy(()->service.history(allowed,42))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND));
verify(database).readTransaction(any());
verify(database).queryForList("SELECT jb.jb_id FROM job jb WHERE jb.jb_id=? AND jb.hq_id=?",42L,7L);
verifyNoMoreInteractions(database);
}
@Test void bindsOrderAndHomeBranchInBothReadsAndSortsChronologicallyWithStableTieBreak() {
when(database.queryForList(anyString(),any(Object[].class)))
.thenReturn(List.of(Map.of("jb_id",42L)),List.of());
assertThat(service.history(allowed,42)).isEmpty();
ArgumentCaptor<String> sql=ArgumentCaptor.forClass(String.class);
ArgumentCaptor<Object[]> args=ArgumentCaptor.forClass(Object[].class);
verify(database,times(2)).queryForList(sql.capture(),args.capture());
assertThat(sql.getAllValues().get(0)).contains("WHERE jb.jb_id=? AND jb.hq_id=?");
assertThat(sql.getAllValues().get(1)).contains("FROM `phoenix_log`.log l", "JOIN job jb ON jb.jb_id=l.jb_id",
"LEFT JOIN `phoenix_log`.logoperation op", "WHERE jb.jb_id=? AND jb.hq_id=?",
"ORDER BY l.log_createtime,l.log_id").doesNotContain("l.hq_id=?", "LIMIT", "DESC");
for(Object[] parameters:args.getAllValues())assertThat(parameters).containsExactly(42L,7L);
verify(database).readTransaction(any());
}
@Test void mapsExistingHistoryRows() {
when(database.queryForList(anyString(),any(Object[].class))).thenReturn(
List.of(Map.of("jb_id",42L)),
List.of(Map.of("log_id",81L,"logo_id",1,"log_createtime","2026-10-07 08:15:00",
"logo_op_name","Auftrag angelegt","usr_account","disponent")));
var result=service.history(allowed,42);
assertThat(result).hasSize(1);
assertThat(result.getFirst().id).isEqualTo(81L);
assertThat(result.getFirst().at).isEqualTo("2026-10-07T08:15:00");
assertThat(result.getFirst().action).isNotBlank();
}
@Test void logDatabaseFailureReturns503InsteadOfAnEmptyHistoryOrDatabaseDetails() {
when(database.queryForList(anyString(),any(Object[].class)))
.thenReturn(List.of(Map.of("jb_id",42L)))
.thenThrow(new DataAccessResourceFailureException("private database credentials"));
assertThatThrownBy(()->service.history(allowed,42))
.isInstanceOfSatisfying(ResponseStatusException.class,error -> {
assertThat(error.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(error.getReason()).contains("Auftragshistorie").doesNotContain("credentials");
});
}
@Test void readsBothLogTablesFromConfiguredSchema() {
var configured=new LegacyHistoryService(database,"tenant_42_log");
when(database.queryForList(anyString(),any(Object[].class)))
.thenReturn(List.of(Map.of("jb_id",42L)),List.of());
configured.history(allowed,42);
verify(database).queryForList(argThat(sql -> sql.contains("FROM `tenant_42_log`.log l")
&& sql.contains("LEFT JOIN `tenant_42_log`.logoperation op")),eq(42L),eq(7L));
}
@Test void rejectsInvalidOrInjectedSchemaNamesBeforeReadingDatabase() {
for(String schema:List.of("", "log; DELETE FROM job", "log` JOIN user --", "server.log", "log-name", "a".repeat(65))) {
assertThatThrownBy(()->new LegacyHistoryService(database,schema)).isInstanceOf(IllegalArgumentException.class);
}
assertThatThrownBy(()->new LegacyHistoryService(database,null)).isInstanceOf(IllegalArgumentException.class);
verifyNoInteractions(database);
}
}
@@ -7,6 +7,39 @@ import static org.assertj.core.api.Assertions.*;
import static de.stadtbote.votian.Models.*;
class LegacyMappingTest {
@Test void mapsHeadquartersOrderCustomerWithoutCustomerEnteredOrderer() {
var order=LegacyMapping.order(Map.of("jb_id",1,"csc_id",0,
"app_customer_id",15,"app_customer_name","Rechnungskunde",
"app_payer_customer_id",15,"app_cost_center","Zentrale"));
assertThat(order.customerId).isEqualTo(15L);
assertThat(order.customerName).isEqualTo("Rechnungskunde");
assertThat(order.payerCustomerId).isEqualTo(15L);
assertThat(order.ordererCustomerId).isNull();
assertThat(order.costCenter).isEqualTo("Zentrale");
}
@Test void keepsCashCustomerPayerAndOrdererSeparate() {
var order=LegacyMapping.order(Map.of("jb_id",1,"jb_payment",2,
"app_customer_id",31,"app_customer_name","Barzahler",
"app_payer_customer_id",19,"app_orderer_customer_id",15));
assertThat(order.customerId).isEqualTo(31L);
assertThat(order.customerName).isEqualTo("Barzahler");
assertThat(order.payerCustomerId).isEqualTo(19L);
assertThat(order.ordererCustomerId).isEqualTo(15L);
}
@Test void preservesUnknownCustomerInsteadOfInventingAnIdOrName() {
var row=new HashMap<String,Object>(Map.of("jb_id",1,"app_customer_id",0,
"app_payer_customer_id",0,"app_orderer_customer_id",0));
row.put("app_customer_name",null);
var order=LegacyMapping.order(row);
assertThat(order.customerId).isNull();
assertThat(order.customerName).isEmpty();
assertThat(order.payerCustomerId).isNull();
assertThat(order.ordererCustomerId).isNull();
assertThat(LegacyMapping.order(Map.of("jb_id",2)).customerName).isEmpty();
}
@Test void mapsBooleanCompanyFlagsForActiveAndArchivedCustomers() {
var row=new HashMap<String,Object>(Map.of("cs_id",1,"cmp_archived",false,"cmp_visible",true,"cmp_authenticated",true));
assertThat(LegacyMapping.customer(row).active).isTrue();
@@ -34,9 +34,48 @@ class LegacyReadServiceTest {
ArgumentCaptor<String> sql=ArgumentCaptor.forClass(String.class);
ArgumentCaptor<Object[]> args=ArgumentCaptor.forClass(Object[].class);
verify(database).queryForList(sql.capture(),args.capture());
assertThat(sql.getValue()).contains("jb.hq_id = ?","cc.cs_id = ?","jb.cr_id = ?","LIMIT 500").doesNotContain("OR 1=1");
assertThat(args.getValue()[0]).isEqualTo(7L);
assertThat(Arrays.asList(args.getValue())).contains("%' OR 1=1 --%");
assertThat(sql.getValue()).contains("jb.hq_id = ?","AND customer_cc.cs_id = ?","jb.cr_id = ?","LIMIT 500",
"LEFT JOIN customer cs ON cs.cs_id = customer_cc.cs_id","cmp.cmp_comp LIKE ?")
.doesNotContain("OR 1=1","AND cc.cs_id = ?");
assertThat(args.getValue()).containsExactly(7L,LocalDate.of(2026,10,1).atStartOfDay(),
LocalDate.of(2026,10,6).atStartOfDay(),23L,42L,
"%' OR 1=1 --%","%' OR 1=1 --%","%' OR 1=1 --%",
"%' OR 1=1 --%","%' OR 1=1 --%","%' OR 1=1 --%");
verify(database).queryForObject(eq("SELECT COUNT(*) "+sql.getValue().substring(
sql.getValue().indexOf("FROM job jb"),sql.getValue().indexOf(" ORDER BY jb.jb_ordertime"))),
eq(Long.class),eq(args.getValue()));
}
@Test void listIncludesPayerCustomerForHeadquartersOrderWithoutOrderer() {
when(database.queryForObject(anyString(),eq(Long.class),any(Object[].class))).thenReturn(1L);
when(database.queryForList(startsWith("SELECT jb.*"),any(Object[].class))).thenReturn(List.of(Map.of(
"jb_id",99,"csc_id",0,"app_customer_id",23,"app_customer_name","Rechnungskunde",
"app_payer_customer_id",23,"app_cost_center","Buchhaltung")));
var result=service.orders(allowed,null,null,null,null,null,null);
assertThat(result.total()).isEqualTo(1);
assertThat(result.orders()).hasSize(1);
var order=result.orders().getFirst();
assertThat(order.customerId).isEqualTo(23L);
assertThat(order.customerName).isEqualTo("Rechnungskunde");
assertThat(order.payerCustomerId).isEqualTo(23L);
assertThat(order.ordererCustomerId).isNull();
assertThat(order.costCenter).isEqualTo("Buchhaltung");
verify(database).queryForList(contains("customer_cc.cs_id AS app_customer_id, cmp.cmp_comp AS app_customer_name"),eq(7L));
}
@Test void detailUsesSameCustomerProjectionAndKeepsDifferentOrderer() {
when(database.queryForList(startsWith("SELECT jb.*"),eq(99L),eq(7L))).thenReturn(List.of(Map.of(
"jb_id",99,"app_customer_id",23,"app_customer_name","Rechnungskunde",
"app_payer_customer_id",23,"app_orderer_customer_id",42)));
var order=service.order(allowed,99L);
assertThat(order.customerId).isEqualTo(23L);
assertThat(order.customerName).isEqualTo("Rechnungskunde");
assertThat(order.payerCustomerId).isEqualTo(23L);
assertThat(order.ordererCustomerId).isEqualTo(42L);
verify(database).queryForList(eq(LegacyReadService.ORDER_SELECT+LegacyReadService.ORDER_FROM
+" WHERE jb.jb_id=? AND jb.hq_id=?"),eq(99L),eq(7L));
}
@Test void singleOrderCannotEscapeHeadquarters() {
when(database.queryForList(anyString(),any(Object[].class))).thenReturn(List.of());
@@ -40,7 +40,8 @@ class DatabaseDiagnosticsTest {
@SuppressWarnings("unchecked")
Map<String, List<String>> missing = (Map<String, List<String>>) result.details().get("missingColumns");
assertThat(missing).doesNotContainKey("headquarters");
assertThat(missing.get("job")).contains("jb_totalprice", "csc_id_payer").doesNotContain("jb_id");
assertThat(missing.get("job")).contains("jb_totalprice", "csc_id_payer", "csc_id_payer_cash", "jb_payment")
.doesNotContain("jb_id");
verify(database).checkConnection();
verify(database).queryForObject("SELECT VERSION()", String.class);
verify(database).queryForList(metadataSql);