Files
deskhub/DeskHub-Webseite/tests/osticket_import.php
T
2026-10-16 19:12:24 +00:00

222 lines
21 KiB
PHP

<?php
declare(strict_types=1);
/**
* Prüft den osTicket-Import (includes/osticket_import*.php) mit einem selbst erzeugten Dump in zwei Formaten:
* phpMyAdmin (Spaltenliste, ein Datensatz je Zeile, hex-Blobs) und mysqldump (ohne Spaltenliste, alle Datensätze in einer Zeile,
* anderes Tabellenpräfix, maskierte Blobs). Enthält die schwierigen Fälle: Anführungszeichen, Backslashes, Klammern und Kommas im
* Text, Zeilenumbrüche, leere Nachrichten, gelöschte Tickets, Anhänge, zu kleine Analyse-Blöcke.
*/
$uploads = sys_get_temp_dir() . '/dh-os-uploads-' . bin2hex(random_bytes(4));
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
require_once dirname(__DIR__) . '/includes/osticket_import_passes.php';
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
$total++;
if (!$ok) $failures[] = $what;
};
// ── Dump erzeugen ────────────────────────────────────────────────────────────
$q = fn($s) => $s === null ? 'NULL' : "'" . str_replace(["\\", "'", "\n", "\r", "\0", "\x1a"], ["\\\\", "\\'", "\\n", "\\r", "\\0", "\\Z"], (string) $s) . "'";
$ticketTable = [
// id, number, user, status, dept, topic, staff, source, created, closed, lastupdate, overdue
[1, '100001', 1, 1, 1, 1, 1, 'Web', '2024-01-10 08:00:00', null, '2024-01-10 09:00:00', 1],
[2, '100002', 2, 2, 5, 2, 0, 'Email', '2024-01-11 08:00:00', '2024-01-12 10:00:00', '2024-01-12 10:00:00', 0],
[3, '100003', 3, 3, 1, 0, 1, 'Phone', '2024-01-12 08:00:00', '2024-01-13 10:00:00', '2024-01-13 10:00:00', 0],
[4, '100004', 1, 5, 1, 1, 0, 'API', '2024-01-13 08:00:00', null, '2024-01-13 08:00:00', 0], // gelöscht
[5, '100005', 2, 4, 1, 1, 0, 'Other', '2024-01-14 08:00:00', '2024-01-15 08:00:00', '2024-01-15 08:00:00', 0], // archiviert
];
$subjects = [1 => "Drucker 'kaputt', (dringend)", 2 => 'Rechnung \\ fehlt', 3 => 'Ünïcödé Ticket ✓', 4 => 'Gelöscht', 5 => 'Archiviert'];
$prioOfTicket = [1 => 4, 2 => 1, 3 => 2, 4 => 3, 5 => 3];
$bigBody = "<p>Hallo,</p><p>ich habe ein Problem mit dem Gerät (Modell X-1, 'Pro'), siehe \\\\server\\share.</p><p>Zeile 1<br>Zeile 2),(1,'x',</p><blockquote>zitierter Text</blockquote>";
$entries = [
// id, thread, staff, user, type, poster, body, format, created
[1, 1, 0, 1, 'M', 'Kunde Eins', $bigBody, 'html', '2024-01-10 08:00:00'],
[2, 1, 1, 0, 'R', 'Support Admin', "<p>Wir kümmern uns.</p>\n<p>Gruß</p>", 'html', '2024-01-10 09:00:00'],
[3, 1, 1, 0, 'N', 'Support Admin', 'Interne Notiz: Kunde ist VIP', 'text', '2024-01-10 09:05:00'],
[4, 2, 0, 2, 'M', 'Kunde Zwei', 'Wo ist meine Rechnung?', 'text', '2024-01-11 08:00:00'],
[5, 2, 0, 2, 'M', 'Kunde Zwei', '', 'html', '2024-01-11 08:30:00'], // leer, mit Anhang
[6, 2, 0, 2, 'M', 'Kunde Zwei', '<p> </p>', 'html', '2024-01-11 08:40:00'], // leer, ohne Anhang
[7, 3, 0, 3, 'M', 'Kunde Drei', 'Hilfe', 'text', '2024-01-12 08:00:00'],
[8, 4, 0, 1, 'M', 'Kunde Eins', 'Gehört zu gelöschtem Ticket', 'text', '2024-01-13 08:00:00'],
];
$collab = [[1, 1, 1, 3]]; // id, flags, thread, user
$users = [[1, 'Kunde Eins', 1], [2, "O'Brien, Pat (Kunde)", 2], [3, '', 3], [4, 'Ohne Adresse', 0]];
$emails = [[1, 1, 'eins@example.test'], [2, 2, 'pat@example.test'], [3, 3, 'drei@example.test']];
$file1 = "\x89PNG\r\n" . str_repeat("A'B\\C\0D", 40); // enthält Anführungszeichen, Backslash, NUL
$file2 = random_bytes(3000);
$files = [[1, 'T', 'image/png', strlen($file1), 'logo.png'], [2, 'T', 'application/pdf', strlen($file2), 'bericht.pdf'], [3, 'T', 'image/png', 10, 'inline.png']];
$attachments = [[1, 5, 'H', 1, null, 0], [2, 4, 'H', 2, 'Rechnung 4711.pdf', 0], [3, 2, 'H', 3, null, 1], [4, 99, 'T', 3, null, 1]];
/** Dump im gewünschten Format schreiben. */
$makeDump = function (string $format, string $prefix) use ($q, $ticketTable, $subjects, $prioOfTicket, $entries, $collab, $users, $emails, $files, $attachments, $file1, $file2): string {
$path = tempnam(sys_get_temp_dir(), 'osdump') . '.sql';
$out = "-- Dump ($format)\nSET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\nSTART TRANSACTION;\n\n";
$table = function (string $name, array $cols, array $rows) use (&$out, $format, $prefix, $q): void {
$t = $prefix . $name;
$out .= "CREATE TABLE `{$t}` (\n `id` int(11) NOT NULL\n) ENGINE=InnoDB;\n\n";
if (!$rows) return;
$tuples = array_map(fn($r) => '(' . implode(', ', array_map(fn($v) => is_int($v) ? (string) $v : (is_string($v) && str_starts_with($v, '0x') ? $v : $q($v)), $r)) . ')', $rows);
if ($format === 'phpmyadmin') {
$out .= "INSERT INTO `{$t}` (`" . implode('`, `', $cols) . "`) VALUES\n" . implode(",\n", $tuples) . ";\n\n";
} else {
$out .= "INSERT INTO `{$t}` VALUES " . implode(',', $tuples) . ";\n\n";
}
};
$T = osticket_tables();
// Unrelated noise (Sitzungen mit Zeilenumbruch-freiem Datenmüll) darf nichts auslösen
$out .= "INSERT INTO `{$prefix}session` (`session_id`, `session_data`) VALUES\n('abc', 'INSERT INTO `{$prefix}user` VALUES (999,1,1,0,\\'Falsch\\',NOW(),NOW())'),\n('def', '(1,2,3)');\n\n";
$table('department', $T['department'], [
[1, 0, 0, 0, 0, 0, 0, 0, 0, 'Support', '', 1, 0, 1, 1, '1', '2024-01-01 00:00:00', '2024-01-01 00:00:00'],
[5, 0, 0, 0, 0, 0, 0, 0, 0, 'Buchhaltung', '', 1, 0, 1, 1, '5', '2024-01-01 00:00:00', '2024-01-01 00:00:00'],
]);
$table('help_topic', $T['help_topic'], [
[1, 0, 1, 1, 1, 0, 0, 0, 4, 1, 0, 0, 0, 0, 0, 0, 'Technisches Problem', '', '', '2024-01-01 00:00:00', '2024-01-01 00:00:00'],
[2, 0, 1, 1, 1, 0, 0, 0, 2, 5, 0, 0, 0, 0, 0, 0, 'Rechnungsfrage', '', '', '2024-01-01 00:00:00', '2024-01-01 00:00:00'],
]);
$table('staff', $T['staff'], [[1, 1, 1, 'admin', 'Support', 'Admin', 'x', 'local', 'admin@example.com'], [2, 1, 1, 'weg', 'Ex', 'Kollege', 'x', 'local', 'weg@example.test']]);
$table('ticket_priority', $T['ticket_priority'], [[1, 'low', 'Niedrig', '#ddd', 4, 1], [2, 'normal', 'Normal', '#ddd', 3, 1], [3, 'high', 'Hoch', '#ddd', 2, 1], [4, 'emergency', 'Notfall', '#ddd', 1, 1]]);
$table('ticket_status', $T['ticket_status'], [[1, 'Offen', 'open', 3, 0, 1, '{}', '2024-01-01 00:00:00', '2024-01-01 00:00:00'], [2, 'Gelöst', 'closed', 1, 0, 2, '{}', '2024-01-01 00:00:00', '2024-01-01 00:00:00'],
[3, 'Geschlossen', 'closed', 1, 0, 3, '{}', '2024-01-01 00:00:00', '2024-01-01 00:00:00'], [4, 'Archiviert', 'archived', 3, 0, 4, '{}', '2024-01-01 00:00:00', '2024-01-01 00:00:00'],
[5, 'Gelöscht', 'deleted', 3, 0, 5, '{}', '2024-01-01 00:00:00', '2024-01-01 00:00:00']]);
$table('user', $T['user'], array_map(fn($u) => [$u[0], 0, $u[2], 0, $u[1], '2024-01-01 00:00:00', '2024-01-01 00:00:00'], $users));
$table('user_email', $T['user_email'], array_map(fn($e) => [$e[0], $e[1], 0, $e[2]], $emails));
$table('ticket', $T['ticket'], array_map(fn($t) => [$t[0], null, $t[1], $t[2], 0, $t[3], $t[4], 1, $t[5], $t[6], 0, 0, 0, 0, 0, '10.0.0.1', $t[7], null, $t[11], 0, null, null, null, $t[9], $t[10], $t[8], $t[10]], $ticketTable));
$table('ticket__cdata', $T['ticket__cdata'], array_map(fn($t) => [$t[0], $subjects[$t[0]], $prioOfTicket[$t[0]]], $ticketTable));
$table('thread', $T['thread'], array_map(fn($t) => [$t[0], $t[0], 'T', null, null, null, $t[8]], $ticketTable));
$table('thread_collaborator', $T['thread_collaborator'], array_map(fn($c) => [$c[0], $c[1], $c[2], $c[3], 'M', '2024-01-01 00:00:00', '2024-01-01 00:00:00'], $collab));
$table('thread_entry', $T['thread_entry'], array_map(fn($e) => [$e[0], 0, $e[1], $e[2], $e[3], $e[4], 0, $e[5], '', '', '', '', $e[6], $e[7], '10.0.0.1', null, null, $e[8], $e[8]], $entries));
$table('attachment', $T['attachment'], array_map(fn($a) => [$a[0], $a[1], $a[2], $a[3], $a[4], $a[5], null], $attachments));
$table('file', $T['file'], array_map(fn($f) => [$f[0], $f[1], 'D', $f[2], $f[3], 'key' . $f[0], 'sig' . $f[0], $f[4], null, '2024-01-01 00:00:00'], $files));
// Blob-Daten: phpMyAdmin als 0x…, mysqldump als maskierter String; Datei 1 in zwei Teilen
$blob = fn(string $d) => $format === 'phpmyadmin' ? '0x' . bin2hex($d) : $q($d);
$parts = [[1, 0, substr($file1, 0, 100)], [1, 1, substr($file1, 100)], [2, 0, $file2], [3, 0, 'x'], [99, 0, 'nicht gebraucht']];
foreach ($parts as $p) {
if ($format === 'phpmyadmin') $out .= "INSERT INTO `{$prefix}file_chunk` (`file_id`, `chunk_id`, `filedata`) VALUES\n({$p[0]}, {$p[1]}, " . $blob($p[2]) . ");\n\n";
else $out .= "INSERT INTO `{$prefix}file_chunk` VALUES ({$p[0]},{$p[1]}," . $blob($p[2]) . ");\n";
}
$out .= "\nCREATE TABLE `{$prefix}form` (\n `id` int\n);\nCOMMIT;\n";
file_put_contents($path, $out);
return $path;
};
// ── Import-Lauf ──────────────────────────────────────────────────────────────
$runImport = function (string $dump, int $blockBytes, array $passes) use ($check): array {
$pdo = get_db();
$dir = sys_get_temp_dir() . '/dh-os-state-' . bin2hex(random_bytes(4));
mkdir($dir, 0700, true);
$offset = 0;
$steps = 0;
do {
$a = osticket_analyze_step($dump, $dir, $offset, $blockBytes);
$offset = $a['offset'];
$steps++;
} while (!$a['done'] && $steps < 100000);
$results = ['analysis' => $a['counts'], 'steps' => $steps, 'passes' => [], 'notes' => []];
foreach ($passes as $pass) {
$off = 0; $ins = 0; $skip = 0; $guard = 0;
do {
$r = osticket_import_step($pdo, $dir, $pass, $off, $dump);
$ins += $r['inserted']; $skip += $r['skipped']; $off = $r['next_offset'];
foreach ($r['notes'] as $n) $results['notes'][] = $n;
} while (!$r['done'] && ++$guard < 10000);
$results['passes'][$pass] = [$ins, $skip];
}
osticket_state_clear($dir);
return $results;
};
$pdo = get_db();
$origUploads = config()['uploads']['path'];
$allPasses = ['users', 'structure', 'tickets', 'threads', 'attachments'];
foreach (['phpmyadmin' => 'ost_', 'mysqldump' => 'wf_'] as $format => $prefix) {
// Frische Datenbank für jedes Format
foreach (['ticket_attachments', 'ticket_collaborators', 'ticket_threads', 'tickets', 'help_topics', 'users'] as $t) $pdo->exec("DELETE FROM {$t}");
$pdo->exec("DELETE FROM departments WHERE name IN ('Support','Buchhaltung')");
$pdo->exec("DELETE FROM help_topics WHERE name IN ('Technisches Problem','Rechnungsfrage')");
$label = "[$format] ";
$dump = $makeDump($format, $prefix);
$res = $runImport($dump, 700, $allPasses); // winzige Blöcke: Zeilen- und Anweisungsgrenzen werden mehrfach überschritten
$check($res['steps'] > 5, $label . 'Analyse läuft in vielen kleinen Schritten');
$an = $res['analysis'];
$check($an['users'] === 3 && $an['departments'] === 2 && $an['topics'] === 2 && $an['tickets'] === 4 && $an['threads'] === 8 && $an['attachments'] === 2, $label . 'Analyse zählt richtig: ' . json_encode($an));
$check($res['passes']['users'] === [3, 1], $label . 'Kunden: 3 importiert, 1 ohne Adresse übersprungen: ' . json_encode($res['passes']['users']));
$check((int) $pdo->query("SELECT COUNT(*) FROM users WHERE name = 'Falsch'")->fetchColumn() === 0, $label . 'Text in anderen Tabellen wird nicht als Kunde gelesen');
$names = $pdo->query('SELECT email, name FROM users ORDER BY email')->fetchAll(PDO::FETCH_KEY_PAIR);
$check(($names['pat@example.test'] ?? '') === "O'Brien, Pat (Kunde)" && ($names['eins@example.test'] ?? '') === 'Kunde Eins', $label . 'Kundennamen bleiben erhalten (auch mit Komma, Klammern, Apostroph)');
$check(($names['drei@example.test'] ?? '') === 'drei', $label . 'Kunde ohne Namen bekommt den Anfang der E-Mail-Adresse');
$check($res['passes']['structure'][0] === 2 + 2, $label . 'Abteilungen und Hilfethemen werden angelegt: ' . json_encode($res['passes']['structure']));
$dept = $pdo->query("SELECT id FROM departments WHERE name = 'Buchhaltung'")->fetchColumn();
$topic = $pdo->query("SELECT department_id, priority_id FROM help_topics WHERE name = 'Rechnungsfrage'")->fetch();
$check($dept && $topic && (int) $topic['department_id'] === (int) $dept, $label . 'Hilfethema gehört zur richtigen (importierten) Abteilung');
$check($res['passes']['tickets'] === [4, 1], $label . 'Tickets: 4 importiert (inkl. archiviertem), das gelöschte übersprungen: ' . json_encode($res['passes']['tickets']));
$tk = $pdo->query('SELECT t.*, d.name AS dept, h.name AS topic, p.name AS prio, s.email AS staff FROM tickets t JOIN departments d ON d.id = t.department_id LEFT JOIN help_topics h ON h.id = t.help_topic_id JOIN priorities p ON p.id = t.priority_id LEFT JOIN staff s ON s.id = t.assigned_staff_id')->fetchAll();
$by = array_column($tk, null, 'ticket_number');
$val = fn(string $n, string $k) => array_key_exists($n, $by) ? $by[$n][$k] : '<<fehlt>>';
$check(isset($by['100001'], $by['100002'], $by['100003']) && !isset($by['100004']), $label . 'gelöschtes Ticket fehlt, übrige sind da');
$check(($by['100001']['subject'] ?? '') === "Drucker 'kaputt', (dringend)" && ($by['100003']['subject'] ?? '') === 'Ünïcödé Ticket ✓' && ($by['100002']['subject'] ?? '') === 'Rechnung \\ fehlt', $label . 'Betreffs mit Sonderzeichen stimmen');
$check(($by['100001']['dept'] ?? '') === 'Support' && ($by['100002']['dept'] ?? '') === 'Buchhaltung', $label . 'Abteilung wird übernommen');
$check(($by['100001']['topic'] ?? '') === 'Technisches Problem' && ($by['100002']['topic'] ?? '') === 'Rechnungsfrage' && $val('100003', 'topic') === null, $label . 'Hilfethema wird übernommen');
$check(($by['100001']['prio'] ?? '') === 'Dringend' && ($by['100002']['prio'] ?? '') === 'Niedrig' && ($by['100003']['prio'] ?? '') === 'Normal', $label . 'Priorität wird über die Rangfolge zugeordnet');
$check(($by['100001']['status'] ?? '') === 'open' && ($by['100002']['status'] ?? '') === 'resolved' && ($by['100003']['status'] ?? '') === 'closed', $label . 'Status wird zugeordnet (offen, gelöst, geschlossen)');
$check(($by['100001']['staff'] ?? '') === 'admin@example.com' && $val('100002', 'staff') === null, $label . 'Zuweisung über gleiche E-Mail-Adresse');
$check(($by['100001']['source'] ?? '') === 'web' && ($by['100002']['source'] ?? '') === 'email' && ($by['100003']['source'] ?? '') === 'phone', $label . 'Quelle wird übernommen');
$check(($by['100002']['resolved_at'] ?? '') === '2024-01-12 10:00:00' && $val('100001', 'resolved_at') === null && ($by['100001']['updated_at'] ?? '') === '2024-01-10 09:00:00', $label . 'Zeitstempel (gelöst, zuletzt geändert)');
$check(($by['100001']['first_responded_at'] ?? '') === '2024-01-10 09:00:00' && $val('100001', 'first_response_due_at') === null, $label . 'erste Antwort wird aus der ersten Mitarbeiter-Antwort abgeleitet');
$check((int) ($by['100001']['is_overdue'] ?? 0) === 1 && ($by['100001']['requester_name'] ?? '') === 'Kunde Eins' && ($by['100001']['requester_email'] ?? '') === 'eins@example.test', $label . 'Überfällig-Flag und Anfragender');
$check(in_array('Gelöschte osTicket-Tickets wurden nicht importiert.', $res['notes'], true), $label . 'Hinweis zu gelöschten Tickets');
$check(isset($by['100005']) === true && ($by['100005']['status'] ?? '') === 'closed', $label . 'archiviertes Ticket wird als geschlossen übernommen');
$th = $pdo->query('SELECT t.ticket_number, th.author_type, th.is_internal_note, th.message, th.created_at, th.staff_id FROM ticket_threads th JOIN tickets t ON t.id = th.ticket_id ORDER BY th.created_at, th.id')->fetchAll();
$check(count($th) === 6, $label . 'Nachrichten: 6 erwartet (1 leer ohne Anhang, 1 zu gelöschtem Ticket übersprungen), gefunden ' . count($th));
$first = $th[0] ?? [];
$check(str_contains((string) ($first['message'] ?? ''), "'Pro'") && str_contains((string) $first['message'], '\\\\server\\share') && str_contains((string) $first['message'], "X-1") && str_contains((string) $first['message'], "Zeile 2),(1,'x',") && !str_contains((string) $first['message'], 'zitierter'), $label . 'Kundentext mit Anführungszeichen, Backslashes und "),(" bleibt heil, Zitat wird entfernt');
$check(($first['author_type'] ?? '') === 'customer', $label . 'erste Nachricht stammt vom Kunden');
$types = array_count_values(array_map(fn($t) => $t['author_type'] . ($t['is_internal_note'] ? '-note' : ''), $th));
$check(($types['staff'] ?? 0) === 1 && ($types['staff-note'] ?? 0) === 1 && ($types['customer'] ?? 0) === 4, $label . 'Antwort, interne Notiz und Kundennachrichten richtig getrennt: ' . json_encode($types));
$reply = array_values(array_filter($th, fn($t) => $t['author_type'] === 'staff' && !$t['is_internal_note']))[0] ?? [];
$check(str_contains((string) ($reply['message'] ?? ''), 'Wir kümmern uns') && (int) ($reply['staff_id'] ?? 0) > 0, $label . 'Mitarbeiter-Antwort ist dem Mitarbeiter zugeordnet');
$noText = array_values(array_filter($th, fn($t) => str_contains((string) $t['message'], 'siehe Anhang')));
$check(count($noText) === 1, $label . 'Nachricht ohne Text, aber mit Anhang, bleibt als Platzhalter erhalten');
$check((int) $pdo->query('SELECT COUNT(*) FROM ticket_collaborators')->fetchColumn() === 1, $label . 'Mitlesende werden übernommen');
// Anhänge
$att = $pdo->query('SELECT a.original_name, a.stored_name, a.filesize FROM ticket_attachments a ORDER BY a.original_name')->fetchAll();
$check(count($att) === 2, $label . 'zwei Anhänge (Inline-Bilder und Vorlagen-Logos zählen nicht): ' . count($att));
$upl = rtrim((string) config()['uploads']['path'], '/') . '/';
$byName = array_column($att, null, 'original_name');
$check(isset($byName['Rechnung 4711.pdf']) && is_file($upl . $byName['Rechnung 4711.pdf']['stored_name']) && file_get_contents($upl . $byName['Rechnung 4711.pdf']['stored_name']) === $file2, $label . 'Binärer Anhang kommt byte-genau an');
$check(isset($byName['logo.png']) && file_get_contents($upl . $byName['logo.png']['stored_name']) === $file1, $label . 'Anhang aus zwei Teilen mit Anführungszeichen, Backslash und NUL kommt byte-genau an');
$check(isset($byName['Rechnung 4711.pdf']) && str_ends_with($byName['Rechnung 4711.pdf']['stored_name'], '.pdf') && preg_match('/^[a-f0-9]{32}\.(pdf|png)$/', $byName['logo.png']['stored_name'] ?? ''), $label . 'gespeicherte Dateinamen sind zufällig, Endung bleibt');
$check(!glob(sys_get_temp_dir() . '/dh-os-state-*/files/*.part') , $label . 'keine Reste von Zwischendateien');
foreach ($att as $a) @unlink($upl . $a['stored_name']);
// Zweiter Lauf: nichts wird doppelt angelegt
$before = [(int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM ticket_threads')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM departments')->fetchColumn()];
$res2 = $runImport($dump, 5000, ['users', 'structure', 'tickets', 'threads']);
$after = [(int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM ticket_threads')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM departments')->fetchColumn()];
$check($before === $after, $label . 'wiederholter Import legt nichts doppelt an: ' . json_encode([$before, $after]));
@unlink($dump);
}
// ── Parser im Detail ─────────────────────────────────────────────────────────
$rows = iterator_to_array(osticket_rows("(1, 'a,b', NULL, 0xAB), (2, 'it\\'s (x),(y)', '''q''', 3.5, 'a\\\\b', 'zeile\\nzwei')"), false);
$check($rows === [['1', 'a,b', null, '0xAB'], ['2', "it's (x),(y)", "'q'", '3.5', 'a\\b', "zeile\nzwei"]], 'Zeilenparser: Kommas, Klammern, Apostrophe, Backslashes, NULL, hex');
$check(osticket_table_key('myprefix_thread_entry') === 'thread_entry' && osticket_table_key('ost_thread_entry_email') === null && osticket_table_key('ost_user__cdata') === null && osticket_table_key('ost_ticket__cdata') === 'ticket__cdata', 'Tabellennamen mit beliebigem Präfix werden erkannt, ähnliche Tabellen nicht verwechselt');
$h = osticket_parse_header("INSERT INTO `ost_x_ticket` (`a`, `b`) VALUES (1, 'x'),(2,'y');");
$check($h[0] === 'ost_x_ticket' && $h[1] === ['a', 'b'] && str_starts_with($h[2], '(1,'), 'Kopfzeile mit Spaltenliste und Daten in derselben Zeile');
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "osTicket import checks passed ({$total} Prüfungen).\n";