Compare commits
2
Commits
ac5ea374b9
...
42c87282ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42c87282ee | ||
|
|
75e2bf2bec |
No files matched your search
@@ -5,3 +5,6 @@
|
||||
DB_URL=jdbc:mariadb://DB-HOST:3306/DB-NAME?connectTimeout=3000&socketTimeout=5000&allowMultiQueries=false
|
||||
DB_USER=CHANGE_ME
|
||||
DB_PASSWORD=CHANGE_ME
|
||||
# Protokolltabellen log/logoperation auf demselben Server, gelesen mit DB_USER.
|
||||
# Nur nötig, wenn das vorhandene Schema anders heißt:
|
||||
# HISTORY_DB_SCHEMA=phoenix_log
|
||||
Vendored
+55
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "VoTiAn NG: Backend",
|
||||
"type": "java",
|
||||
"request": "launch",
|
||||
"mainClass": "de.stadtbote.votian.VotianApplication",
|
||||
"projectName": "votian-backend",
|
||||
"cwd": "${workspaceFolder}/backend",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"args": [
|
||||
"--server.address=127.0.0.1",
|
||||
"--server.port=8080"
|
||||
],
|
||||
"console": "internalConsole"
|
||||
},
|
||||
{
|
||||
"name": "VoTiAn NG: Frontend + Chrome",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/frontend/node_modules/vite/bin/vite.js",
|
||||
"cwd": "${workspaceFolder}/frontend",
|
||||
"args": [
|
||||
"--host", "127.0.0.1",
|
||||
"--port", "5173",
|
||||
"--strictPort"
|
||||
],
|
||||
"console": "internalConsole",
|
||||
"outputCapture": "std",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"serverReadyAction": {
|
||||
"pattern": "Local:\\s+(https?://\\S+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome",
|
||||
"webRoot": "${workspaceFolder}/frontend",
|
||||
"killOnServerStop": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "VoTiAn NG: Backend + Frontend",
|
||||
"configurations": [
|
||||
"VoTiAn NG: Backend",
|
||||
"VoTiAn NG: Frontend + Chrome"
|
||||
],
|
||||
"stopAll": true,
|
||||
"presentation": {
|
||||
"group": "VoTiAn NG",
|
||||
"order": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -39,6 +39,16 @@ python3 scripts/run-backend.py --check-config
|
||||
|
||||
Die neue Anwendung liest `connection.local.json` und die PHP-Konfiguration nicht mehr als Zugangsdatenquelle. Der PHP-Altbestand bleibt eigenständig; seine Konfiguration wird durch diese Umstellung nicht verändert.
|
||||
|
||||
## Debuggen in VS Code
|
||||
|
||||
Den Projektstamm `votianNG` in VS Code öffnen. Benötigt werden das **Extension Pack for Java**, Java 21 und Google Chrome. Die Frontend-Abhängigkeiten einmalig mit `npm ci --prefix frontend` installieren und die lokale `.env` wie oben beschrieben einrichten.
|
||||
|
||||
Unter **Ausführen und Debuggen** die Konfiguration **VoTiAn NG: Backend + Frontend** auswählen und mit `F5` starten. Das Backend wird vom Java-Debugger gebaut und mit der `.env` auf `127.0.0.1:8080` gestartet. Vite läuft auf `127.0.0.1:5173` und öffnet automatisch Chrome mit aktiviertem Browser-Debugger. Haltepunkte sind sowohl in Java als auch in React/TypeScript möglich. Die beiden Komponenten lassen sich über die Einzelkonfigurationen auch separat starten.
|
||||
|
||||
Die Ports sind im Debugstart festgelegt, passend zum Vite-API-Proxy. Ein bereits laufendes `dev.sh` vorher beenden. Beim Stoppen einer Komponente des gemeinsamen Starts werden beide Server und die zugehörige Browser-Debugsitzung beendet.
|
||||
|
||||
Der Java-Debugger liest `.env` mit dem üblichen dotenv-Parser: `$` bleibt wörtlich, ein unquotiertes `#` beginnt dagegen einen Kommentar. Bei solchen Werten sind Anführungszeichen erforderlich; für Docker gegebenenfalls eine separate Datei mit unquotierten Werten verwenden.
|
||||
|
||||
## Gebündelte Anwendung
|
||||
|
||||
```bash
|
||||
|
||||
+12
-5
@@ -4,7 +4,7 @@ Java 21, Spring Boot 3.5.16 und MariaDB JDBC. Dieser Adapter verwendet ausschlie
|
||||
|
||||
## Zentrale Datenbankserviceschicht
|
||||
|
||||
`database.DatabaseService` ist die einzige Komponente mit Zugriff auf `JdbcTemplate` und den Transaktionsmanager. `LegacyAuthenticationProvider`, `LegacyReadService` und `database.DatabaseDiagnostics` verwenden ausschließlich diesen Service; auch Integrationstests lesen den Bestand über ihn. Der Service aktiviert `setEnforceReadOnly(true)`, sodass jede Lesetransaktion mit `SET TRANSACTION READ ONLY` serverseitig abgesichert wird. Der JDBC-Hinweis allein reicht hierfür beim verwendeten MariaDB-Treiber nicht aus.
|
||||
`database.DatabaseService` ist die einzige Komponente mit Zugriff auf `JdbcTemplate` und den Transaktionsmanager. `LegacyAuthenticationProvider`, `LegacyReadService`, `LegacyHistoryService` und `database.DatabaseDiagnostics` verwenden ausschließlich diesen Service; auch Integrationstests lesen den Bestand über ihn. Der Service aktiviert `setEnforceReadOnly(true)`, sodass jede Lesetransaktion mit `SET TRANSACTION READ ONLY` serverseitig abgesichert wird. Der JDBC-Hinweis allein reicht hierfür beim verwendeten MariaDB-Treiber nicht aus.
|
||||
|
||||
- `queryForList(sql, parameters...)` und `queryForObject(sql, type, parameters...)` führen einzelne parametrisierte SELECT-Abfragen in Lesetransaktionen aus.
|
||||
- `readTransaction(work)` fasst abhängige Abfragen zusammen; Auftragsliste einschließlich Anzahl und Stationen sowie Auftragsdetails verwenden diese Grenze.
|
||||
@@ -31,6 +31,7 @@ Verfügbare Konfiguration:
|
||||
| --- | --- |
|
||||
| `DB_URL` | JDBC-Adresse der bestehenden DB, z. B. `jdbc:mariadb://DB-HOST:3306/DB-NAME?connectTimeout=2000&socketTimeout=5000` |
|
||||
| `DB_USER`, `DB_PASSWORD` | Vorhandenes Datenbankkonto; keine Zugangsdaten im Quelltext |
|
||||
| `HISTORY_DB_SCHEMA` | Bestehendes Protokollschema auf demselben Server, Standard `phoenix_log`; benötigt SELECT-Rechte für `log` und `logoperation` mit demselben Datenbankkonto. Name aus Buchstaben, Ziffern und Unterstrichen, maximal 64 Zeichen |
|
||||
| `LEGACY_APP_URL` | Optionale HTTP(S)-Basisadresse der vorhandenen PHP-Anwendung |
|
||||
| `SERVER_ADDRESS` | Standard `127.0.0.1` |
|
||||
| `PORT` | Standard `8080` |
|
||||
@@ -78,8 +79,9 @@ Die ursprüngliche Niederlassung `user.hq_id` begrenzt alle Abfragen. Bei Auftr
|
||||
| `POST /api/login` | CSRF-geschützte Anmeldung mit Formularfeldern `username` und `password`; `200` mit Benutzerinformationen |
|
||||
| `POST /api/logout` | CSRF-geschützte Abmeldung, Sitzung ungültig machen und Cookie löschen; `204` |
|
||||
| `GET /api/me` | Bestehendes Konto, Niederlassung, verfügbare Lesebereiche |
|
||||
| `GET /api/orders` | Aufträge, Filter `q`, `status`, `customerId`, `courierId`, `from`, `to` |
|
||||
| `GET /api/orders` | Aufträge mit Kundennamen; Filter `q`, `status`, `customerId` (effektiver Zahler), `courierId`, `from`, `to` |
|
||||
| `GET /api/orders/{id}` | Auftrag derselben Erfassungsniederlassung mit Tourstationen |
|
||||
| `GET /api/orders/{id}/history` | Vollständige Historie eines aktuellen Auftrags derselben Erfassungsniederlassung; Recht 4; aufsteigend nach Ereigniszeit und Log-ID |
|
||||
| `GET /api/orders/export.csv` | Gefilterter UTF-8-Export, zusätzlich Recht 5; verweigert einen unvollständigen Export oberhalb der Ergebnisgrenze |
|
||||
| `GET /api/customers`, `GET /api/couriers` | Stammdaten, optionaler Suchparameter `q` |
|
||||
| `GET /api/invoices` | 501, Bestands-Abrechnungsprozess noch nicht portiert |
|
||||
@@ -89,13 +91,16 @@ Listen liefern Arrays. Auftragslisten senden zusätzlich `X-Total-Count` und `X-
|
||||
|
||||
## Fachliche Zuordnung und offene Arbeit
|
||||
|
||||
- Auftraggeber aus `job.csc_id → costcenter.cs_id`; Rechnungskunde separat als `payerCustomerId` aus `csc_id_payer`.
|
||||
- Die Kundenanzeige (`customerId`, `customerName`, `costCenter`), Namenssuche und der Kundenfilter folgen `job.csc_id_payer → costcenter → customer → company`. Bei Barzahlung (`jb_payment=2`) mit positiver `csc_id_payer_cash` wird für diese Anzeige der tatsächliche Barzahler verwendet; `payerCustomerId` behält den Kunden aus `csc_id_payer`, also gegebenenfalls den technischen Systembarzahler.
|
||||
- `job.csc_id` bezeichnet die erfassende Kundenkostenstelle und ist bei zentral erfassten Aufträgen regulär `0`. Dieser Bezug bleibt separat als `ordererCustomerId` erhalten. Er darf nicht als allgemeiner Kundenbezug verwendet werden. Quelle: `jobs/job_edit.php:38–48`, `jobs/job_options.php:125–128`, `:1706–1711` und `admin/jb_list.php:1022–1023`.
|
||||
- Der Kundenname wird mit jedem sichtbaren Auftrag geliefert; die Anzeige ist damit unabhängig vom Ergebnislimit oder Leserecht der separaten Kundenstammliste. Die Auftragsabfrage bleibt über `job.hq_id` und Recht 4 begrenzt. Nicht zuordenbare Kunden (beispielsweise Kostensplit ohne eindeutigen Zahler) bleiben `null` und werden in der Oberfläche ausdrücklich als nicht zugeordnet angezeigt.
|
||||
- Kundenpreis aus `jb_totalprice` (Netto einschließlich Zuschlägen), Kurierpreis aus `jb_cr_price`; keine neu erfundene Tarifierung.
|
||||
- Status 0/1/2/8/9 bleiben zusätzlich als `legacyStatus`/`legacyStatusLabel` erhalten. Die UI fasst 8/9 unter Disposition zusammen.
|
||||
- `jb_storno` bleibt als `legacyStorno` erhalten. 3/4 sind Gegenbuchungen und werden ausdrücklich als Storno-Gutschriften markiert (`isCreditNote`).
|
||||
- Tourrichtung 1=Abholung, 2=Zustellung, 3=beides (`BOTH`), 0=positionsabhängige Legacy-Regel.
|
||||
- Leistungen aus der Bitmaske `jb_service`, wobei `metatype.mt_sort` die Bitposition ist. Fahrzeugtypen werden über `metatype` gelesen.
|
||||
- Historie liegt im Bestand in einer separaten Log-Datenbank. Der Adapter erfindet keine Audit-Ereignisse; das Feld bleibt aktuell leer.
|
||||
- Historie wird beim Öffnen von „Verlauf“ separat über `/api/orders/{id}/history` aus `phoenix_log.log` geladen. `logoperation` liefert Vorgangsbezeichnungen, `user` den Bearbeiter. Die Oberfläche zeigt die neuesten Ereignisse zuerst und unterscheidet Laden, eine erfolgreich gelesene leere Historie und einen Zugriffsfehler mit Wiederholungsmöglichkeit. Das bisherige `history`-Feld in Auftragslisten/-details bleibt leer; maßgeblich ist der eigene Historien-Endpunkt.
|
||||
- Die Historienberechtigung folgt `job.hq_id` und Recht 4; Ereignisse anderer Niederlassungen zum selben sichtbaren Auftrag bleiben erhalten. Fehlende/fremde Aufträge liefern 404, Datenbank-/Schemarechtefehler 503. Die Historie wird weder erfunden noch bei einem Fehler durch ein leeres Ergebnis ersetzt. Legacy-Details werden als Text mit bekannten deutschen Feldnamen angezeigt, unbekannte Angaben bleiben erhalten; gespeicherter HTML-Code wird nicht ausgeführt. Es erfolgen keine Schemaänderungen oder Datenbankschreibzugriffe.
|
||||
- Abrechnung im Bestand ist keine nachgewiesene einfache Invoice-CRUD-Tabelle. Belege, Gegenbuchungen, Zahlungsstatus, Tarifierung, Auftragsanlage/Änderung und Fahrerkommunikation bleiben bis zur vollständigen Portierung im vorhandenen PHP-System. Optional verlinkt die React-Oberfläche diese Module.
|
||||
- Archivtabellen und Mehrniederlassungsrechte sind noch nicht eingebunden. Die bestehende Anwendung bleibt für diese Funktionen erforderlich.
|
||||
|
||||
@@ -105,10 +110,12 @@ SQL-/Mapping-Quellen im vorhandenen Code: `include/mcglobal.inc.php`, `include/d
|
||||
|
||||
`./mvnw test` führt Tests ohne Bestandszugriff aus: bestehende Anmeldung und Rechte, Sitzungs- und CSRF-Verhalten, HQ-Abgrenzung, SQL-Parameterbindung, Nichtverfügbarkeit, Verbot fachlicher Schreibpfade, serverseitige Vorbereitung der Lesetransaktion, Transaktions-/Fehlerverhalten, Schemadiagnose, Legacy-Status/Preis-/Tour-Mapping einschließlich JDBC-Boolean-Flags und CSV-Schutz. Normale Maven-Builds und Unit-Tests laden `.env` nicht. Ein Architekturtest prüft die kompilierten Java-Abhängigkeiten und Python-Launcher auf direkte Datenbankzugriffe außerhalb des Services. Nur dessen eigener Unit-Test darf JDBC mocken. Es wird keine Testdatenbank angelegt und keine Bestandsdatenbank verändert.
|
||||
|
||||
Mit `VOTIAN_VERIFY_EXISTING_DB=true` und den konfigurierten `DB_URL`, `DB_USER`, `DB_PASSWORD` wird zusätzlich `LegacyDatabaseReadIntegrationTest` ausgeführt. Er prüft die drei Listen-APIs mit echten SELECT-Abfragen über MockMvc und die numerische JDBC-Abbildung von `TINYINT(1)`. Die Authentifizierung wird nur in diesem Test simuliert; die echte Anmeldung wird damit nicht getestet. Die gewählte Niederlassung muss vorhandene Aufträge, Kunden und Kuriere enthalten. Ohne ausdrückliche Aktivierung bleibt dieser Test deaktiviert.
|
||||
Mit `VOTIAN_VERIFY_EXISTING_DB=true` und den konfigurierten `DB_URL`, `DB_USER`, `DB_PASSWORD` wird zusätzlich `LegacyDatabaseReadIntegrationTest` ausgeführt. Er prüft die drei Listen-APIs mit echten SELECT-Abfragen über MockMvc, die numerische JDBC-Abbildung von `TINYINT(1)` und den Historien-Endpunkt gegen das konfigurierte Protokollschema. Für die Historie muss einer der neuesten 100 aktuellen Aufträge Protokolleinträge besitzen; geprüft werden Anzahl und Antwortformat ohne Ausgabe der Protokollinhalte. Die Authentifizierung wird nur in diesem Test simuliert; die echte Anmeldung wird damit nicht getestet. Die gewählte Niederlassung muss vorhandene Aufträge, Kunden und Kuriere enthalten. Ohne ausdrückliche Aktivierung bleibt dieser Test deaktiviert.
|
||||
|
||||
Für diesen bewusst aktivierten Integrationstest mit der lokalen `.env` im Verzeichnis `backend/` ausführen:
|
||||
|
||||
```sh
|
||||
VOTIAN_VERIFY_EXISTING_DB=true python3 ../scripts/env_config.py ./mvnw test
|
||||
```
|
||||
|
||||
Der Integrationstest vergleicht außerdem die Kundenprojektion der neuesten 100 Aufträge mit unabhängigen Abfragen der Zahler-, Barzahler- und Erfassungskundenbezüge. Auch hierbei enthalten Testausgaben keine Kunden- oder Auftragsinhalte.
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.16</version><relativePath/></parent>
|
||||
<groupId>de.stadtbote</groupId><artifactId>votian-backend</artifactId><version>0.1.1</version>
|
||||
<groupId>de.stadtbote</groupId><artifactId>votian-backend</artifactId><version>0.1.2</version>
|
||||
<name>Votian NG Stadtbote</name><properties><java.version>21</java.version></properties>
|
||||
<dependencies>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
|
||||
|
||||
@@ -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);
|
||||
|
||||
+102
-35
@@ -48,6 +48,7 @@ import {
|
||||
import type {
|
||||
Courier,
|
||||
Customer,
|
||||
History,
|
||||
Invoice,
|
||||
Order,
|
||||
OrderStatus,
|
||||
@@ -117,6 +118,10 @@ const initials = (s: string) =>
|
||||
.map((v) => v[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
const customerLabel = (id: number | null | undefined, name?: string | null) =>
|
||||
name?.trim() || (id != null && id > 0 ? `Kunde #${id}` : "Kunde nicht zugeordnet");
|
||||
const orderCustomerLabel = (order: Order, customer?: Customer) =>
|
||||
customerLabel(order.customerId, order.customerName?.trim() || customer?.name);
|
||||
const emptyStop = (sequence: number): Stop => ({
|
||||
sequence,
|
||||
type: sequence === 1 ? "PICKUP" : "DELIVERY",
|
||||
@@ -540,7 +545,13 @@ function Workspace({
|
||||
? !!system?.capabilities.writeInvoices
|
||||
: canEdit;
|
||||
const customerName = useCallback(
|
||||
(id: number) => customers.find((c) => c.id === id)?.name || `Kunde #${id}`,
|
||||
(id: number | null | undefined) =>
|
||||
customerLabel(id, customers.find((c) => c.id === id)?.name),
|
||||
[customers],
|
||||
);
|
||||
const orderCustomerName = useCallback(
|
||||
(order: Order) =>
|
||||
orderCustomerLabel(order, customers.find((c) => c.id === order.customerId)),
|
||||
[customers],
|
||||
);
|
||||
const courierName = useCallback(
|
||||
@@ -1264,7 +1275,7 @@ function Workspace({
|
||||
</td>
|
||||
<td>
|
||||
<strong className="truncate customer-cell">
|
||||
{customerName(o.customerId)}
|
||||
{orderCustomerName(o)}
|
||||
</strong>
|
||||
<span className="cell-sub">
|
||||
{o.reference || "Keine Referenz"}
|
||||
@@ -1418,7 +1429,7 @@ function Workspace({
|
||||
<strong>{o.number}</strong>
|
||||
<span>{time(o.pickupAt)}</span>
|
||||
</span>
|
||||
<h4>{customerName(o.customerId)}</h4>
|
||||
<h4>{orderCustomerName(o)}</h4>
|
||||
<p>
|
||||
<MapPin size={13} />
|
||||
{o.stops[0]?.city} <ArrowRight size={12} />
|
||||
@@ -1514,7 +1525,7 @@ function Workspace({
|
||||
label={o.legacyStatusLabel}
|
||||
/>
|
||||
</div>
|
||||
<h4>{customerName(o.customerId)}</h4>
|
||||
<h4>{orderCustomerName(o)}</h4>
|
||||
{o.stops.map((s, i) => (
|
||||
<p key={i}>
|
||||
<i
|
||||
@@ -1880,6 +1891,7 @@ function Workspace({
|
||||
)}
|
||||
{selected && (
|
||||
<OrderDetails
|
||||
key={selected.id}
|
||||
order={selected}
|
||||
customer={customers.find((c) => c.id === selected.customerId)}
|
||||
couriers={couriers}
|
||||
@@ -2085,6 +2097,33 @@ function OrderDetails({
|
||||
const [tab, setTab] = useState("details"),
|
||||
[courier, setCourier] = useState(String(o.courierId || "")),
|
||||
[recipient, setRecipient] = useState("");
|
||||
const [history, setHistory] = useState<{
|
||||
order: Order;
|
||||
entries?: History[];
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
const [historyAttempt, setHistoryAttempt] = useState(0);
|
||||
// A refreshed order invalidates the displayed history even if its version
|
||||
// did not change (legacy log entries may be written independently).
|
||||
const currentHistory = history?.order === o ? history : null;
|
||||
useEffect(() => {
|
||||
if (tab !== "history") return;
|
||||
const controller = new AbortController();
|
||||
setHistory({ order: o });
|
||||
void request<History[]>(`/orders/${o.id}/history`, {
|
||||
signal: controller.signal,
|
||||
}).then(
|
||||
(entries) => {
|
||||
if (!controller.signal.aborted)
|
||||
setHistory({ order: o, entries: entries.slice().reverse() });
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!controller.signal.aborted)
|
||||
setHistory({ order: o, error: (error as Error).message });
|
||||
},
|
||||
);
|
||||
return () => controller.abort();
|
||||
}, [o, tab, historyAttempt]);
|
||||
useEffect(() => setCourier(String(o.courierId || "")), [o.courierId]);
|
||||
const actions: Record<OrderStatus, { status: OrderStatus; label: string }[]> =
|
||||
{
|
||||
@@ -2141,15 +2180,18 @@ function OrderDetails({
|
||||
className={tab === "history" ? "active" : ""}
|
||||
onClick={() => setTab("history")}
|
||||
>
|
||||
Verlauf <span>{o.history?.length || "–"}</span>
|
||||
Verlauf{" "}
|
||||
{currentHistory?.entries && (
|
||||
<span>{currentHistory.entries.length}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="drawer-body">
|
||||
{tab === "details" ? (
|
||||
<>
|
||||
<div className="detail-section">
|
||||
<span className="section-label">AUFTRAGGEBER</span>
|
||||
<h3>{customer?.name || `Kunde #${o.customerId}`}</h3>
|
||||
<span className="section-label">KUNDE / ZAHLER</span>
|
||||
<h3>{orderCustomerLabel(o, customer)}</h3>
|
||||
<p>
|
||||
{customer?.contact} {customer?.phone && "· " + customer.phone}
|
||||
</p>
|
||||
@@ -2351,27 +2393,46 @@ function OrderDetails({
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="history-list">
|
||||
{o.history?.length ? (
|
||||
o.history
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((h) => (
|
||||
<div className="history-item" key={h.id}>
|
||||
<span className="history-dot" />
|
||||
<div>
|
||||
<strong>{h.action}</strong>
|
||||
<p>{h.detail}</p>
|
||||
<small>
|
||||
{date(h.at)} · {time(h.at)} · {h.actor}
|
||||
</small>
|
||||
</div>
|
||||
<div className="history-list" aria-label="Auftragshistorie">
|
||||
{currentHistory?.error ? (
|
||||
<div className="history-error">
|
||||
<div className="alert error" role="alert">
|
||||
<strong>Historie konnte nicht geladen werden.</strong>
|
||||
<span>{currentHistory.error}</span>
|
||||
</div>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => setHistoryAttempt((attempt) => attempt + 1)}
|
||||
>
|
||||
<RefreshCw size={15} />
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
) : !currentHistory?.entries ? (
|
||||
<div className="history-loading" role="status">
|
||||
<LoaderCircle className="spin" size={20} />
|
||||
Historie wird geladen …
|
||||
</div>
|
||||
) : currentHistory.entries.length ? (
|
||||
currentHistory.entries.map((h) => (
|
||||
<div className="history-item" key={h.id}>
|
||||
<span className="history-dot" />
|
||||
<div>
|
||||
<strong>{h.action}</strong>
|
||||
<p>{h.detail}</p>
|
||||
<small>
|
||||
{h.at
|
||||
? `${date(h.at)} · ${time(h.at)} Uhr`
|
||||
: "Zeitpunkt unbekannt"}
|
||||
{h.actor && ` · ${h.actor}`}
|
||||
</small>
|
||||
</div>
|
||||
))
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<Empty
|
||||
title="Historie nicht angebunden"
|
||||
detail="Die separate Legacy-Protokolldatenbank ist für diese Ansicht noch nicht angebunden."
|
||||
title="Keine Historie vorhanden"
|
||||
detail="Für diesen Auftrag sind keine Protokolleinträge vorhanden."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -3383,15 +3444,21 @@ function Reports({
|
||||
),
|
||||
).sort((a, b) => b[1] - a[1]);
|
||||
const customerRank = [...new Set(data.map((o) => o.customerId))]
|
||||
.map((id) => ({
|
||||
id,
|
||||
name: customers.find((c) => c.id === id)?.name || `Kunde #${id}`,
|
||||
number: customers.find((c) => c.id === id)?.number || "",
|
||||
count: data.filter((o) => o.customerId === id).length,
|
||||
total: sumValues(
|
||||
data.filter((o) => o.customerId === id).map((o) => o.netPrice),
|
||||
),
|
||||
}))
|
||||
.map((id) => {
|
||||
const customerOrders = data.filter((o) => o.customerId === id);
|
||||
const customer = customers.find((c) => c.id === id);
|
||||
return {
|
||||
id,
|
||||
name: customerLabel(
|
||||
id,
|
||||
customerOrders.find((o) => o.customerName?.trim())?.customerName ||
|
||||
customer?.name,
|
||||
),
|
||||
number: customer?.number || "",
|
||||
count: customerOrders.length,
|
||||
total: sumValues(customerOrders.map((o) => o.netPrice)),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 6);
|
||||
const courierRank = [
|
||||
@@ -3523,7 +3590,7 @@ function Reports({
|
||||
</thead>
|
||||
<tbody>
|
||||
{customerRank.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<tr key={String(c.id)}>
|
||||
<td>
|
||||
<strong>{c.name}</strong>
|
||||
<span className="cell-sub">{c.number}</span>
|
||||
|
||||
@@ -1840,6 +1840,18 @@ main {
|
||||
.history-list {
|
||||
padding: 25px 0;
|
||||
}
|
||||
.history-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 40px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.history-error .alert {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.history-item {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
@@ -1878,6 +1890,8 @@ main {
|
||||
font-size: 10px;
|
||||
color: #b695c6;
|
||||
margin-top: 5px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.history-item small {
|
||||
display: block;
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface Stop {
|
||||
}
|
||||
export interface History {
|
||||
id: number;
|
||||
at: string;
|
||||
at: string | null;
|
||||
action: string;
|
||||
detail: string;
|
||||
actor: string;
|
||||
@@ -31,7 +31,9 @@ export interface Order {
|
||||
legacyStorno?: number;
|
||||
isCreditNote?: boolean;
|
||||
payerCustomerId?: number | null;
|
||||
customerId: number;
|
||||
ordererCustomerId?: number | null;
|
||||
customerId: number | null;
|
||||
customerName?: string | null;
|
||||
courierId: number | null;
|
||||
service: string;
|
||||
vehicle: string;
|
||||
|
||||
+232
-4
@@ -29,10 +29,7 @@ const system = {
|
||||
legacyUrl: "https://legacy.example.test/votian",
|
||||
maxResults: 500,
|
||||
capabilities,
|
||||
unavailableModules: [
|
||||
"Rechnungserstellung und Zahlungsstatus",
|
||||
"Historie aus separater Log-Datenbank",
|
||||
],
|
||||
unavailableModules: ["Rechnungserstellung und Zahlungsstatus"],
|
||||
};
|
||||
const customers = [
|
||||
{
|
||||
@@ -192,6 +189,7 @@ async function mockApi(
|
||||
"/couriers": couriers,
|
||||
"/orders": orders,
|
||||
"/orders/880001": orders[0],
|
||||
"/orders/880001/history": [],
|
||||
...overrides,
|
||||
}[path];
|
||||
if (path === "/orders" && url.searchParams.get("q")) {
|
||||
@@ -465,6 +463,236 @@ test("existing account opens order list, details and module navigation with read
|
||||
});
|
||||
});
|
||||
|
||||
for (const customerAccess of ["empty", "denied", "loaded"] as const) {
|
||||
test(`order customer names remain available with ${customerAccess} customer master data`, async ({ page }) => {
|
||||
const order = { ...orders[0], customerName: " Testzahler Westwind " };
|
||||
let customerRequests = 0;
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname === "/api/customers") customerRequests++;
|
||||
});
|
||||
await mockApi(page, {
|
||||
"/system": {
|
||||
...system,
|
||||
capabilities: { ...capabilities, readCustomers: customerAccess !== "denied" },
|
||||
},
|
||||
"/customers": customerAccess === "loaded" ? customers : [],
|
||||
"/orders": [order],
|
||||
"/orders/880001": order,
|
||||
});
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("row", { name: /880001/ })).toContainText("Testzahler Westwind");
|
||||
await page.getByText("880001").first().click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByText("KUNDE / ZAHLER", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByRole("heading", { name: "Testzahler Westwind", exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Details schließen", exact: true }).click();
|
||||
for (const module of ["Disposition", "Touren", "Auswertung"]) {
|
||||
await page.getByRole("button", { name: module, exact: true }).click();
|
||||
await expect(page.locator("main").getByText("Testzahler Westwind", { exact: true })).toBeVisible();
|
||||
await expect(page.locator("main").getByText("Testkunde Nordlicht", { exact: true })).toHaveCount(0);
|
||||
}
|
||||
if (customerAccess === "denied") expect(customerRequests).toBe(0);
|
||||
else expect(customerRequests).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
test("orders and reports show safe customer fallbacks for missing identities and names", async ({ page }) => {
|
||||
const variants = [
|
||||
{ customerId: null, customerName: null, label: "Kunde nicht zugeordnet" },
|
||||
{ customerId: 0, customerName: "", label: "Kunde nicht zugeordnet" },
|
||||
{ customerId: undefined, customerName: undefined, label: "Kunde nicht zugeordnet" },
|
||||
{ customerId: 44, customerName: " ", label: "Kunde #44" },
|
||||
{ customerId: 11, customerName: " ", label: "Testkunde Nordlicht" },
|
||||
];
|
||||
const fixture = variants.map(({ label: _label, ...customer }, index) => ({
|
||||
...orders[0], ...customer, id: 880001 + index, number: String(880001 + index),
|
||||
}));
|
||||
await mockApi(page, { "/orders": fixture });
|
||||
await page.goto("/");
|
||||
for (const [index, { label }] of variants.entries()) {
|
||||
const orderNumber = fixture[index].number;
|
||||
const row = page.getByRole("row").filter({ has: page.getByText(orderNumber, { exact: true }) });
|
||||
await expect(row).toContainText(label);
|
||||
await row.getByText(orderNumber, { exact: true }).click();
|
||||
await expect(page.getByRole("dialog").getByRole("heading", { name: label, exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Details schließen", exact: true }).click();
|
||||
}
|
||||
await page.getByRole("button", { name: "Auswertung", exact: true }).click();
|
||||
await expect(page.locator("main").getByText("Kunde nicht zugeordnet", { exact: true })).toHaveCount(3);
|
||||
await expect(page.locator("main").getByText("Kunde #44", { exact: true })).toBeVisible();
|
||||
await expect(page.locator("main").getByText("Testkunde Nordlicht", { exact: true })).toBeVisible();
|
||||
await expect(page.locator("main")).not.toContainText(/Kunde #(null|undefined|0)/);
|
||||
});
|
||||
|
||||
test("customer reports aggregate by customer ID and use a name from any matching order", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
"/customers": [],
|
||||
"/orders": [
|
||||
{ ...orders[0], customerName: null },
|
||||
{ ...orders[0], id: 880002, number: "880002", customerName: "Testzahler Westwind" },
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Auswertung", exact: true }).click();
|
||||
const row = page.getByRole("row").filter({ hasText: "Testzahler Westwind" });
|
||||
await expect(row).toHaveCount(1);
|
||||
await expect(row.getByRole("cell").nth(1)).toHaveText("2");
|
||||
await expect(row.getByRole("cell").nth(2)).toHaveText("85,00 €");
|
||||
});
|
||||
|
||||
test("order history loads on demand and shows newest events with safe multiline details", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockApi(page);
|
||||
const detail = "Alt: 10 €\nNeu: 42,50 €\n<script>alert('Protokoll')</script>";
|
||||
const history = [
|
||||
{ id: 1, at: null, action: "Importiert", detail: "Altdaten", actor: "" },
|
||||
{
|
||||
id: 2,
|
||||
at: `${today}T08:20:00`,
|
||||
action: "Angelegt",
|
||||
detail: "Auftrag erfasst",
|
||||
actor: "dispo.alt",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
at: `${today}T09:20:00`,
|
||||
action: "Geändert",
|
||||
detail,
|
||||
actor: "dispo.neu",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
at: `${today}T09:20:00`,
|
||||
action: "Geprüft",
|
||||
detail: "Preis geprüft",
|
||||
actor: "dispo.neu",
|
||||
},
|
||||
];
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||
let requests = 0;
|
||||
await page.route("**/api/orders/880001/history", async (route) => {
|
||||
requests++;
|
||||
await pending;
|
||||
await route.fulfill({ json: history });
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByText("880001").first().click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByRole("button", { name: "Verlauf", exact: true })).toBeVisible();
|
||||
expect(requests).toBe(0);
|
||||
await dialog.getByRole("button", { name: "Verlauf", exact: true }).click();
|
||||
await expect(dialog.getByRole("status")).toHaveText("Historie wird geladen …");
|
||||
await expect(dialog.getByText("Keine Historie vorhanden")).toHaveCount(0);
|
||||
release();
|
||||
await expect(dialog.getByRole("button", { name: "Verlauf 4", exact: true })).toBeVisible();
|
||||
await expect(dialog.locator(".history-item strong")).toHaveText([
|
||||
"Geprüft", "Geändert", "Angelegt", "Importiert",
|
||||
]);
|
||||
const changed = dialog.locator(".history-item").filter({ hasText: "Geändert" });
|
||||
expect(await changed.locator("p").textContent()).toBe(detail);
|
||||
await expect(changed.locator("p")).toHaveCSS("white-space", "pre-wrap");
|
||||
await expect(changed.locator("small")).toContainText("09:20 Uhr · dispo.neu");
|
||||
await expect(dialog.locator(".history-item").last()).toContainText("Zeitpunkt unbekannt");
|
||||
await expect(dialog.locator("script")).toHaveCount(0);
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
|
||||
test("order history distinguishes unavailable data from empty history and retries", async ({ page }) => {
|
||||
await mockApi(page);
|
||||
let attempts = 0;
|
||||
await page.route("**/api/orders/880001/history", async (route) => {
|
||||
attempts++;
|
||||
if (attempts === 1)
|
||||
return route.fulfill({
|
||||
status: 503,
|
||||
json: { message: "Protokolldatenbank nicht erreichbar." },
|
||||
});
|
||||
return route.fulfill({ json: [] });
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByText("880001").first().click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("button", { name: "Verlauf", exact: true }).click();
|
||||
await expect(dialog.getByRole("alert")).toContainText("Protokolldatenbank nicht erreichbar.");
|
||||
await expect(dialog.getByText("Keine Historie vorhanden")).toHaveCount(0);
|
||||
await expect(dialog.getByRole("button", { name: "Verlauf", exact: true })).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Erneut versuchen", exact: true }).click();
|
||||
await expect(dialog.getByText("Keine Historie vorhanden", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByRole("button", { name: "Verlauf 0", exact: true })).toBeVisible();
|
||||
await expect(dialog.getByRole("alert")).toHaveCount(0);
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
|
||||
test("order history is refreshed with the selected order even when its version is unchanged", async ({ page }) => {
|
||||
await mockApi(page);
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("880001").first()).toBeVisible();
|
||||
await page.clock.install();
|
||||
await page.getByLabel("Automatisch aktualisieren").check();
|
||||
let requests = 0;
|
||||
await page.route("**/api/orders/880001/history", async (route) => {
|
||||
requests++;
|
||||
return route.fulfill({
|
||||
json: [{ id: requests, at: `${today}T09:00:00`, action: `Stand ${requests}`, detail: "", actor: "dispo" }],
|
||||
});
|
||||
});
|
||||
await page.getByText("880001").first().click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("button", { name: "Verlauf", exact: true }).click();
|
||||
await expect(dialog.locator(".history-item strong")).toHaveText(["Stand 1"]);
|
||||
await page.clock.fastForward(30_000);
|
||||
await expect(dialog.locator(".history-item strong")).toHaveText(["Stand 2"]);
|
||||
expect(requests).toBe(2);
|
||||
});
|
||||
|
||||
test("a late history response cannot replace the history of another order", async ({ page }) => {
|
||||
const secondOrder = { ...orders[0], id: 880002, number: "880002" };
|
||||
await mockApi(page, {
|
||||
"/orders": [orders[0], secondOrder],
|
||||
"/orders/880002/history": [
|
||||
{ id: 2, at: null, action: "Zweiter Auftrag", detail: "Aktuelle Historie", actor: "dispo" },
|
||||
],
|
||||
});
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||
let responded!: () => void;
|
||||
const responseFinished = new Promise<void>((resolve) => { responded = resolve; });
|
||||
await page.route("**/api/orders/880001/history", async (route) => {
|
||||
await pending;
|
||||
await route.fulfill({
|
||||
json: [{ id: 1, at: null, action: "Veraltete Historie", detail: "Erster Auftrag", actor: "dispo" }],
|
||||
});
|
||||
responded();
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByText("880001").first().click();
|
||||
await page.getByRole("button", { name: "Verlauf", exact: true }).click();
|
||||
await expect(page.getByRole("dialog").getByRole("status")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Details schließen", exact: true }).click();
|
||||
await page.getByText("880002").first().click();
|
||||
await page.getByRole("button", { name: "Verlauf", exact: true }).click();
|
||||
await expect(page.getByRole("dialog").locator(".history-item strong")).toHaveText(["Zweiter Auftrag"]);
|
||||
release();
|
||||
await responseFinished;
|
||||
await expect(page.getByRole("dialog").locator(".history-item strong")).toHaveText(["Zweiter Auftrag"]);
|
||||
await expect(page.getByText("Veraltete Historie")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("session expiry while loading order history clears the drawer and account data", async ({ page }) => {
|
||||
const session = await mockApi(page);
|
||||
await page.goto("/");
|
||||
await page.getByText("880001").first().click();
|
||||
session.expire();
|
||||
await page.getByRole("button", { name: "Verlauf", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible();
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(page.getByText("880001")).toHaveCount(0);
|
||||
await expect(page.getByText("Testkunde Nordlicht")).toHaveCount(0);
|
||||
await expectNoStoredCredentials(page);
|
||||
});
|
||||
|
||||
test("missing module rights do not trigger forbidden API calls", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in new issue
Block a user