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:
1 parent
ac5ea374b9
commit
75e2bf2bec
24 files changed
+1069
-60
No files matched your search
@@ -16,8 +16,9 @@ import static de.stadtbote.votian.Models.*;
|
||||
@RequestMapping("/api")
|
||||
public class ApiController {
|
||||
private final LegacyReadService legacy;
|
||||
private final LegacyHistoryService history;
|
||||
private final String legacyUrl;
|
||||
ApiController(LegacyReadService legacy,@Value("${app.legacy-url:}") String legacyUrl) { this.legacy=legacy;this.legacyUrl=safeLegacyUrl(legacyUrl); }
|
||||
ApiController(LegacyReadService legacy,LegacyHistoryService history,@Value("${app.legacy-url:}") String legacyUrl) { this.legacy=legacy;this.history=history;this.legacyUrl=safeLegacyUrl(legacyUrl); }
|
||||
static String safeLegacyUrl(String raw) {
|
||||
if(raw==null || raw.isBlank())return null;
|
||||
URI uri=URI.create(raw.trim());
|
||||
@@ -41,7 +42,7 @@ public class ApiController {
|
||||
result.put("message",connection.message());result.put("reason",connection.reason());
|
||||
result.put("legacyUrl",legacyUrl);result.put("maxResults",legacy.maxResults());
|
||||
result.put("capabilities",u==null?new Capabilities(false,false,false,false,false,false,false,false,false,false):u.capabilities());
|
||||
result.put("unavailableModules",List.of("Rechnungserstellung und Zahlungsstatus","Änderungen an Aufträgen und Stammdaten","Historie aus separater Log-Datenbank","Archivtabellen"));
|
||||
result.put("unavailableModules",List.of("Rechnungserstellung und Zahlungsstatus","Änderungen an Aufträgen und Stammdaten","Archivtabellen"));
|
||||
return result;
|
||||
}
|
||||
@GetMapping("/me") public Map<String,Object> me(Authentication authentication) {
|
||||
@@ -55,6 +56,7 @@ public class ApiController {
|
||||
return ResponseEntity.ok().header("X-Total-Count",Long.toString(result.total())).header("X-Result-Limit",Integer.toString(result.limit())).body(result.orders());
|
||||
}
|
||||
@GetMapping("/orders/{id}") public DeliveryOrder order(Authentication auth,@PathVariable long id) { return legacy.order(user(auth),id); }
|
||||
@GetMapping("/orders/{id}/history") public List<History> history(Authentication auth,@PathVariable long id) { return history.history(user(auth),id); }
|
||||
@GetMapping("/customers") public List<Customer> customers(Authentication auth,@RequestParam(required=false) String q) { return legacy.customers(user(auth),q); }
|
||||
@GetMapping("/couriers") public List<Courier> couriers(Authentication auth,@RequestParam(required=false) String q) { return legacy.couriers(user(auth),q); }
|
||||
@GetMapping("/orders/export.csv") public ResponseEntity<byte[]> csv(Authentication auth,@RequestParam(required=false) String q,@RequestParam(required=false) OrderStatus status,@RequestParam(required=false) Long customerId,@RequestParam(required=false) Long courierId,@RequestParam(required=false) LocalDate from,@RequestParam(required=false) LocalDate to) {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
|
||||
/** Read-only projection of phoenix_log.log; legacy descriptions are returned as plain text. */
|
||||
final class LegacyHistoryMapping {
|
||||
private static final Pattern KEY = Pattern.compile("\\$?[A-Za-z_][A-Za-z0-9_]*");
|
||||
private static final Pattern BREAK = Pattern.compile("(?i)<\\s*/?(?:br|p|div|tr|li)(?:\\s[^>]*)?/?>");
|
||||
private static final Pattern TAG = Pattern.compile(
|
||||
"(?i)</?(?:b|strong|i|em|u|s|small|span|font|a|img|table|tbody|thead|td|th|ul|ol)(?:\\s[^>]*)?/?>");
|
||||
private static final Map<String, String> LABELS = Map.ofEntries(
|
||||
Map.entry("SID", "Fahrzeug"),
|
||||
Map.entry("CR_SID", "Fahrzeug"),
|
||||
Map.entry("JB_ID_ORG", "Vorlageauftrag"),
|
||||
Map.entry("JB_ID_ORI", "Dauerauftragskopie von"),
|
||||
Map.entry("JB_ID_ORI_COPY", "Multiauftragskopie von"),
|
||||
Map.entry("HQ_ID_EXEC", "Ausführende Niederlassung (ID)"),
|
||||
Map.entry("ORDERTIME", "Auftragszeit"),
|
||||
Map.entry("JB_ORDERTIME", "Auftragszeit"),
|
||||
Map.entry("JB_DISPOINFO", "Dispo-Info"),
|
||||
Map.entry("JB_WARNINGTIME", "Datum der Dispo-Info"),
|
||||
Map.entry("JB_TOTALPRICE", "Auftragspreis"),
|
||||
Map.entry("JB_FIXPRICE", "Fixpreis"),
|
||||
Map.entry("JB_SERVICEPRICE", "Servicepreis"),
|
||||
Map.entry("JB_CR_PRICE", "Frachtvergütung"),
|
||||
Map.entry("JB_CR_SERVICEPRICE", "Servicevergütung"),
|
||||
Map.entry("JB_WEIGHT", "Gewicht"),
|
||||
Map.entry("JB_KM", "Kilometer"),
|
||||
Map.entry("JB_COMMISSION_NO", "Kommissionsnummer"),
|
||||
Map.entry("STATION_ID", "Station (ID)"),
|
||||
Map.entry("TR_ADD", "Hinzugefügte Stationen"),
|
||||
Map.entry("TR_PERSON", "Ansprechpartner"),
|
||||
Map.entry("TR_REMARK", "Bemerkungen"),
|
||||
Map.entry("TR_COMMISSION_NO", "Kommissionsnummer"),
|
||||
Map.entry("REMARK_TEXT", "Bemerkung"),
|
||||
Map.entry("REMARK_ITEMS", "Bemerkungscodes"),
|
||||
Map.entry("REASON", "Grund"),
|
||||
Map.entry("MAIL_STATE", "Versandstatus"),
|
||||
Map.entry("MAIL_TO", "Empfänger"),
|
||||
Map.entry("STATUS", "Status"),
|
||||
Map.entry("STATUS_NEW", "Neuer Status"),
|
||||
Map.entry("COSTS", "Kostenpflichtig"),
|
||||
Map.entry("JB_RHYTHM_DEL", "Dauerauftragsregel (Code)"),
|
||||
Map.entry("JBP_REMOVAL", "Aus Abrechnung entfernt"),
|
||||
Map.entry("FDS_REMOVAL", "Aus FDS entfernt"));
|
||||
|
||||
private LegacyHistoryMapping() {}
|
||||
|
||||
static Models.History history(Map<String, Object> row) {
|
||||
Models.History history = new Models.History();
|
||||
history.id = LegacyMapping.id(row, "log_id");
|
||||
history.at = LegacyMapping.dateTime(row, "log_createtime");
|
||||
history.action = firstText(row, "logo_op_shortname", "logo_op_name");
|
||||
if (history.action.isBlank()) {
|
||||
long operationId = LegacyMapping.id(row, "logo_id");
|
||||
history.action = operationId > 0 ? "Vorgang " + operationId : "Unbekannter Vorgang";
|
||||
}
|
||||
history.actor = LegacyMapping.join(plainText(LegacyMapping.text(row, "usr_firstname")),
|
||||
plainText(LegacyMapping.text(row, "usr_name")));
|
||||
if (history.actor.isBlank()) history.actor = firstText(row, "usr_account");
|
||||
if (history.actor.isBlank()) {
|
||||
long userId = LegacyMapping.id(row, "usr_id");
|
||||
history.actor = userId > 0 ? "Benutzer " + userId : "Nicht hinterlegt";
|
||||
}
|
||||
history.detail = description(LegacyMapping.text(row, "logo_description"));
|
||||
String vehicle = plainText(LegacyMapping.text(row, "cr_sid"));
|
||||
if (!vehicle.isBlank()) {
|
||||
history.detail = "Fahrzeug: " + vehicle + (history.detail.isBlank() ? "" : "\n" + history.detail);
|
||||
}
|
||||
return history;
|
||||
}
|
||||
|
||||
/** The legacy parser splits on pipes and the first equals sign, retaining further equals signs. */
|
||||
static String description(String raw) {
|
||||
if (raw == null || raw.isBlank()) return "";
|
||||
List<String> lines = new ArrayList<>();
|
||||
for (String part : raw.split("\\|", -1)) {
|
||||
String fragment = part.strip();
|
||||
if (fragment.isEmpty()) continue;
|
||||
int equals = fragment.indexOf('=');
|
||||
String key = equals < 0 ? "" : fragment.substring(0, equals).strip();
|
||||
if (KEY.matcher(key).matches()) {
|
||||
// Preserve stored values, including unknown flags and prices, without inferred units or conversions.
|
||||
lines.add(label(key) + ": " + plainText(fragment.substring(equals + 1)));
|
||||
} else {
|
||||
lines.add(plainText(fragment));
|
||||
}
|
||||
}
|
||||
return String.join("\n", lines).strip();
|
||||
}
|
||||
|
||||
private static String label(String raw) {
|
||||
String key = raw.startsWith("$") ? raw.substring(1) : raw;
|
||||
key = key.toUpperCase(Locale.ROOT);
|
||||
String station = "";
|
||||
if (key.startsWith("TR_") && key.matches(".*_\\d+$")) {
|
||||
int underscore = key.lastIndexOf('_');
|
||||
station = " (Station " + key.substring(underscore + 1) + ")";
|
||||
key = key.substring(0, underscore);
|
||||
}
|
||||
boolean old = key.endsWith("_OLD");
|
||||
String base = old ? key.substring(0, key.length() - 4) : key;
|
||||
String known = LABELS.get(base);
|
||||
return known == null ? raw : known + (old ? " (bisher)" : "") + station;
|
||||
}
|
||||
|
||||
private static String firstText(Map<String, Object> row, String... keys) {
|
||||
for (String key : keys) {
|
||||
String value = plainText(LegacyMapping.text(row, key));
|
||||
if (!value.isBlank()) return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String plainText(String value) {
|
||||
// This is a text projection, not HTML for rendering; consumers must continue using text nodes.
|
||||
String text = BREAK.matcher(value).replaceAll("\n");
|
||||
text = TAG.matcher(text).replaceAll("");
|
||||
return HtmlUtils.htmlUnescape(text).replace('\u00a0', ' ').replace("\r\n", "\n").strip();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static de.stadtbote.votian.Models.History;
|
||||
|
||||
/** Reads the existing log schema on the same server through the shared read-only service. */
|
||||
@Service
|
||||
public class LegacyHistoryService {
|
||||
private final DatabaseService database;
|
||||
private final String historySql;
|
||||
|
||||
public LegacyHistoryService(DatabaseService database,
|
||||
@Value("${app.history-schema:phoenix_log}") String schema) {
|
||||
this.database = database;
|
||||
// SQL identifiers cannot be bound as parameters. Only configuration, never request input,
|
||||
// supplies this strictly validated schema name; all record IDs remain bound parameters.
|
||||
if (schema == null || !schema.matches("[A-Za-z0-9_]{1,64}")) {
|
||||
throw new IllegalArgumentException("HISTORY_DB_SCHEMA muss ein Datenbankname aus Buchstaben, Ziffern und Unterstrichen sein (maximal 64 Zeichen).");
|
||||
}
|
||||
String prefix = "`" + schema + "`.";
|
||||
historySql = """
|
||||
SELECT l.log_id,l.logo_id,CAST(l.log_createtime AS CHAR) AS log_createtime,
|
||||
l.usr_id,l.cr_sid,l.logo_description,
|
||||
op.logo_op_shortname,op.logo_op_name,u.usr_firstname,u.usr_name,u.usr_account
|
||||
FROM %slog l
|
||||
JOIN job jb ON jb.jb_id=l.jb_id
|
||||
LEFT JOIN %slogoperation op ON op.logo_id=l.logo_id
|
||||
LEFT JOIN user u ON u.usr_id=l.usr_id
|
||||
WHERE jb.jb_id=? AND jb.hq_id=?
|
||||
ORDER BY l.log_createtime,l.log_id
|
||||
""".formatted(prefix, prefix);
|
||||
}
|
||||
|
||||
public List<History> history(LegacyUser user, long orderId) {
|
||||
user.require(4);
|
||||
return database.readTransaction(() -> {
|
||||
// Check visibility before touching the log schema; a missing/foreign order is never
|
||||
// indistinguishable from an accessible order with no history.
|
||||
var orders = database.queryForList("SELECT jb.jb_id FROM job jb WHERE jb.jb_id=? AND jb.hq_id=?",
|
||||
orderId, user.headquartersId());
|
||||
if (orders.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Auftrag in dieser Niederlassung nicht gefunden.");
|
||||
}
|
||||
try {
|
||||
// Events may have originated in another branch. Scope by the order's home branch,
|
||||
// not l.hq_id, matching the legacy job history's event selection.
|
||||
return database.queryForList(historySql, orderId, user.headquartersId()).stream()
|
||||
.map(LegacyHistoryMapping::history).toList();
|
||||
} catch (DataAccessException exception) {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"Die Auftragshistorie ist derzeit nicht erreichbar. Bitte später erneut versuchen.");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,8 @@ final class LegacyMapping {
|
||||
}
|
||||
static DeliveryOrder order(Map<String,Object> row) {
|
||||
DeliveryOrder o=new DeliveryOrder();o.id=id(row,"jb_id");o.number=Long.toString(o.id);o.version=id(row,"jb_version");
|
||||
o.customerId=nullableId(row,"app_customer_id");o.payerCustomerId=nullableId(row,"app_payer_customer_id");o.courierId=nullableId(row,"cr_id");
|
||||
o.customerId=nullableId(row,"app_customer_id");o.customerName=text(row,"app_customer_name");
|
||||
o.payerCustomerId=nullableId(row,"app_payer_customer_id");o.ordererCustomerId=nullableId(row,"app_orderer_customer_id");o.courierId=nullableId(row,"cr_id");
|
||||
o.legacyStatus=(int)id(row,"jb_status");int storno=(int)id(row,"jb_storno");o.legacyStorno=storno;o.isCreditNote=storno==3||storno==4;
|
||||
o.cancelMode=storno==1 || storno==3?CancelMode.CHARGEABLE:storno==2 || storno==4?CancelMode.FREE:null;
|
||||
o.status=o.cancelMode!=null?OrderStatus.CANCELLED:switch(o.legacyStatus){case 0->OrderStatus.OFFERED;case 1->OrderStatus.IN_PROGRESS;case 2->OrderStatus.COMPLETED;default->OrderStatus.DISPOSITION;};
|
||||
|
||||
@@ -15,14 +15,20 @@ public class LegacyReadService {
|
||||
private final DatabaseService database;
|
||||
private final int maxResults;
|
||||
public LegacyReadService(DatabaseService database,@Value("${app.max-results:500}") int maxResults) { this.database=database;this.maxResults=Math.max(1,Math.min(maxResults,5000)); }
|
||||
// csc_id records customer-entered orders and is normally 0 for HQ entry (job_edit.php).
|
||||
// The displayed customer follows the payer; cash payments may name a separate actual payer.
|
||||
static final String ORDER_FROM="""
|
||||
FROM job jb LEFT JOIN costcenter cc ON cc.csc_id = jb.csc_id
|
||||
LEFT JOIN costcenter payer ON payer.csc_id=jb.csc_id_payer
|
||||
LEFT JOIN customer cs ON cs.cs_id = cc.cs_id
|
||||
LEFT JOIN costcenter customer_cc ON customer_cc.csc_id = CASE
|
||||
WHEN jb.jb_payment=2 AND jb.csc_id_payer_cash>0 THEN jb.csc_id_payer_cash
|
||||
ELSE jb.csc_id_payer END AND customer_cc.csc_id>0
|
||||
LEFT JOIN customer cs ON cs.cs_id = customer_cc.cs_id
|
||||
LEFT JOIN company cmp ON cmp.cmp_id = cs.cmp_id
|
||||
""";
|
||||
static final String ORDER_SELECT="""
|
||||
SELECT jb.*, cc.cs_id AS app_customer_id, payer.cs_id AS app_payer_customer_id, cc.csc_name AS app_cost_center,
|
||||
SELECT jb.*, customer_cc.cs_id AS app_customer_id, cmp.cmp_comp AS app_customer_name,
|
||||
payer.cs_id AS app_payer_customer_id, cc.cs_id AS app_orderer_customer_id, customer_cc.csc_name AS app_cost_center,
|
||||
(SELECT MIN(mt.mt_value) FROM metatype mt WHERE mt.mt_type='vehicletype' AND mt.mt_sort=jb.vht_id) AS vehicle_label,
|
||||
(SELECT GROUP_CONCAT(DISTINCT mt.mt_value ORDER BY mt.mt_sort SEPARATOR ', ') FROM metatype mt WHERE mt.mt_type='service' AND (jb.jb_service & (1 << mt.mt_sort)) <> 0) AS service_label
|
||||
""";
|
||||
@@ -37,7 +43,7 @@ public class LegacyReadService {
|
||||
StringBuilder where=new StringBuilder(" WHERE jb.hq_id = ? ");List<Object> parameters=new ArrayList<>(List.of(user.headquartersId()));
|
||||
if(from!=null){where.append(" AND jb.jb_ordertime >= ?");parameters.add(from.atStartOfDay());}
|
||||
if(to!=null){where.append(" AND jb.jb_ordertime < ?");parameters.add(to.plusDays(1).atStartOfDay());}
|
||||
if(customerId!=null){where.append(" AND cc.cs_id = ?");parameters.add(customerId);}
|
||||
if(customerId!=null){where.append(" AND customer_cc.cs_id = ?");parameters.add(customerId);}
|
||||
if(courierId!=null){where.append(" AND jb.cr_id = ?");parameters.add(courierId);}
|
||||
if(status!=null) {
|
||||
if(status==OrderStatus.CANCELLED) where.append(" AND jb.jb_storno IN (1,2,3,4)");
|
||||
|
||||
@@ -24,7 +24,8 @@ public final class Models {
|
||||
public boolean active;
|
||||
}
|
||||
public static class DeliveryOrder {
|
||||
public Long id, version, customerId, payerCustomerId, courierId, invoiceId;
|
||||
public Long id, version, customerId, payerCustomerId, ordererCustomerId, courierId, invoiceId;
|
||||
public String customerName;
|
||||
public String number;
|
||||
public OrderStatus status;
|
||||
public int legacyStatus, legacyStorno;
|
||||
|
||||
@@ -14,7 +14,7 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class DatabaseDiagnostics {
|
||||
private static final Map<String, List<String>> REQUIRED = Map.ofEntries(
|
||||
Map.entry("job", columns("jb_id hq_id csc_id csc_id_payer cr_id jb_status jb_storno jb_version jb_ordertime jb_totalprice jb_cr_price jb_service jb_commission_no vht_id")),
|
||||
Map.entry("job", columns("jb_id hq_id csc_id csc_id_payer csc_id_payer_cash jb_payment cr_id jb_status jb_storno jb_version jb_ordertime jb_totalprice jb_cr_price jb_service jb_commission_no vht_id")),
|
||||
Map.entry("tour", columns("tr_id jb_id ad_id tr_sort tr_ware_from_to tr_status tr_finishtime tr_signname")),
|
||||
Map.entry("customer", columns("cs_id cmp_id hq_id cs_eid cs_admin")),
|
||||
Map.entry("company", columns("cmp_id cmp_comp ad_id cmp_hsno cmp_archived cmp_visible cmp_authenticated")),
|
||||
|
||||
@@ -41,6 +41,7 @@ app:
|
||||
cors-origins: ${CORS_ORIGINS:http://localhost:5173,http://127.0.0.1:5173}
|
||||
legacy-url: ${LEGACY_APP_URL:}
|
||||
max-results: ${MAX_RESULTS:500}
|
||||
history-schema: ${HISTORY_DB_SCHEMA:phoenix_log}
|
||||
logging:
|
||||
level:
|
||||
com.zaxxer.hikari: WARN
|
||||
|
||||
@@ -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 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> links<br>2. OG & 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=<script>Text</script>"))
|
||||
.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);
|
||||
|
||||
Reference in new issue
Block a user