diff --git a/CHANGELOG.md b/CHANGELOG.md
index 40fb760fd..e99426e9a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,34 @@ ones are marked like "v1.0.0-fork".
the same way `SqlFileParser` does since #241. This is what failed the Windows
PHPUnit jobs. Verified end-to-end against MeCab 0.996 and covered by tests
that assert LF, CRLF and CR-only output tokenize identically.
+* **Restoring a backup wiped the database and reported success** (#249): on a
+ Windows host, "Restore from Backup" emptied the install and restored nothing,
+ while the page still said "Database restored". `Restore::restoreFile` split
+ the dump into statements on `';' . PHP_EOL` — `";\r\n"` on Windows — but LWT
+ always writes `";\n"`, so the split never matched, every statement piled up
+ in the accumulator and the statement list came back empty. The restore then
+ dropped every table and replayed nothing. This is the same defect fixed in
+ `SqlFileParser` for #241; the restore path was missed. Line endings are now
+ normalized before splitting, so a dump restores identically regardless of
+ which OS wrote or reads it. Three further hardening changes make the failure
+ non-destructive if it ever recurs: a dump that parses to zero statements is
+ refused *before* any table is dropped, the restore's own report (query and
+ record counts) is shown verbatim instead of a flat "Database restored", and a
+ final statement not followed by a newline is no longer discarded.
+* **Restore left the database in pieces when foreign keys were enabled**
+ (#249): a dump's `CREATE TABLE` statements come from `SHOW CREATE TABLE`, so
+ they carry their `FOREIGN KEY` clauses, but they are replayed in backup-table
+ order rather than dependency order (`feed_links` references `news_feeds` yet
+ is created before it). With foreign-key checks on, every such statement
+ failed with errno 1215 — on a 12-table backup, 11 of 12 `CREATE TABLE`s were
+ lost. Checks are now suspended for the replay and enforced again once every
+ table exists.
+* **Choosing a restore file did nothing** (#249): the file field stayed empty
+ and the "Restore from Backup" button stayed disabled, because the inline
+ `@change` handler used optional chaining
+ (`$event.target.files[0]?.name || ''`), which `@alpinejs/csp` cannot parse —
+ it threw "CSP Parser Error: Unexpected token" on every selection. The handler
+ moved into the `backupManager` component as `selectFile()`.
### Changed
diff --git a/src/Modules/Admin/Application/AdminFacade.php b/src/Modules/Admin/Application/AdminFacade.php
index 9df9125ba..06ac84723 100644
--- a/src/Modules/Admin/Application/AdminFacade.php
+++ b/src/Modules/Admin/Application/AdminFacade.php
@@ -190,7 +190,7 @@ public function saveAndClearSession(string $key, string $value): void
* @param array{name: string, type: string, tmp_name: string, error: int, size: int}|null $fileData
* Validated file data from InputValidator::getUploadedFile()
*
- * @return array{success: bool, error: ?string}
+ * @return array{success: bool, error: ?string, message?: string}
*/
public function restoreFromUpload(?array $fileData): array
{
diff --git a/src/Modules/Admin/Application/UseCases/Backup/RestoreFromUpload.php b/src/Modules/Admin/Application/UseCases/Backup/RestoreFromUpload.php
index b374e2b08..3a602d9bf 100644
--- a/src/Modules/Admin/Application/UseCases/Backup/RestoreFromUpload.php
+++ b/src/Modules/Admin/Application/UseCases/Backup/RestoreFromUpload.php
@@ -55,7 +55,10 @@ public function __construct(BackupRepositoryInterface $repository)
* @param array{name: string, type: string, tmp_name: string, error: int, size: int}|null $fileData
* Validated file data from InputValidator::getUploadedFile()
*
- * @return array{success: bool, error: ?string}
+ * @return array{success: bool, error: ?string, message?: string}
+ * On success, `message` carries the restore's own report (query
+ * and record counts), which the caller should show verbatim
+ * rather than substituting a generic "restored" string.
*/
public function execute(?array $fileData): array
{
@@ -97,6 +100,6 @@ public function execute(?array $fileData): array
if (str_starts_with($message, 'Error:')) {
return ['success' => false, 'error' => $message];
}
- return ['success' => true, 'error' => null];
+ return ['success' => true, 'error' => null, 'message' => $message];
}
}
diff --git a/src/Modules/Admin/Http/AdminController.php b/src/Modules/Admin/Http/AdminController.php
index efafb3a98..b68d0bce3 100644
--- a/src/Modules/Admin/Http/AdminController.php
+++ b/src/Modules/Admin/Http/AdminController.php
@@ -131,7 +131,13 @@ public function backup(array $params): void
$result = $this->adminFacade->restoreFromUpload(
InputValidator::getUploadedFile('thefile')
);
- $message = $result['success'] ? 'Database restored' : ($result['error'] ?? 'Restore failed');
+ // Show the restore's own report rather than a flat "Database
+ // restored": its query/record counts are the only signal an admin
+ // has that the dump actually replayed. A restore that parsed zero
+ // statements used to look identical to a real one here (#249).
+ $message = $result['success']
+ ? ($result['message'] ?? 'Database restored')
+ : ($result['error'] ?? 'Restore failed');
} elseif ($this->hasParam('backup')) {
$this->adminFacade->downloadBackup();
// downloadBackup exits, so we never reach here
diff --git a/src/Modules/Admin/Views/backup.php b/src/Modules/Admin/Views/backup.php
index 9a7c14661..17891d7ba 100644
--- a/src/Modules/Admin/Views/backup.php
+++ b/src/Modules/Admin/Views/backup.php
@@ -192,7 +192,7 @@
diff --git a/src/Shared/Infrastructure/Database/Restore.php b/src/Shared/Infrastructure/Database/Restore.php
index 788e5effe..811b44221 100644
--- a/src/Shared/Infrastructure/Database/Restore.php
+++ b/src/Shared/Infrastructure/Database/Restore.php
@@ -83,49 +83,33 @@ private static function dropAllLwtTables(): void
}
/**
- * Restore the database from a file.
+ * Read a backup stream and split it into individual SQL statements.
*
- * @param resource $handle Backup file handle
- * @param string $title File title
- * @param bool $validateSql Whether to validate SQL statements (default true)
+ * Statement boundaries are matched platform-independently. LWT always
+ * writes ";\n", but this used to split on ';' . PHP_EOL, which is
+ * ";\r\n" on a Windows host and therefore never matched: every
+ * statement piled up in the accumulator and the caller received an
+ * empty list, dropped every table and replayed nothing (issue #249).
+ * Line endings are now normalised exactly as
+ * SqlFileParser::parseFile() does, so a dump restores identically
+ * regardless of which OS wrote it or reads it.
*
- * @return string Human-readable status message
+ * The handle is always closed before returning.
*
- * @since 2.0.3-fork Function was broken
- * @since 2.5.3-fork Function repaired
- * @since 2.7.0-fork $handle should be an *uncompressed* file.
- * @since 2.9.1-fork It can read SQL with more or less than one instruction a line
- * @since 3.0.0 Added SQL validation for security hardening
+ * @param resource $handle Backup file handle (may be a gzopen handle)
+ * @param string $title File title, used in error messages
+ *
+ * @return array{queries: list, error: string|null}
+ *
+ * @since 3.2.2-fork Extracted from restoreFile() and made EOL-agnostic
*/
- public static function restoreFile($handle, string $title, bool $validateSql = true): string
+ private static function parseBackupStream($handle, string $title): array
{
- // Multi-user safety guard: the rest of this function drops every
- // LWT table and replaces it with the dump's contents, which would
- // wipe every other user's data. Refuse the restore when more
- // than one active user exists. The admin must take the multi-
- // user mode down first if they really mean it (or use the
- // per-user "Empty Database" + manual backup/restore recipe in
- // docs).
- $contextError = self::validateMultiUserRestoreContext();
- if ($contextError !== null) {
- fclose($handle);
- return $contextError;
- }
-
- $message = "";
- $hasErrors = false;
- $install_status = [
- "queries" => 0,
- "successes" => 0,
- "errors" => 0,
- "drops" => 0,
- "inserts" => 0,
- "creates" => 0
- ];
- $start = true;
- $curr_content = '';
$queries_list = [];
+ $curr_content = '';
+ $start = true;
$bytesRead = 0;
+ $error = null;
while ($stream = fgets($handle)) {
// Gzip-bomb defense: cap the total decompressed bytes we
@@ -134,23 +118,26 @@ public static function restoreFile($handle, string $title, bool $validateSql = t
// certainly malicious or a runaway.
$bytesRead += strlen($stream);
if ($bytesRead > self::MAX_DECOMPRESSED_BYTES) {
- $message = "Error: $title Restore file exceeds the "
+ $error = "Error: $title Restore file exceeds the "
. intdiv(self::MAX_DECOMPRESSED_BYTES, 1024 * 1024)
. " MB decompressed-size limit (likely a gzip bomb).";
- $install_status["errors"] = 1;
- $hasErrors = true;
break;
}
+
+ // Normalise line endings before anything is matched against
+ // them. formatValueForSqlOutput() escapes CR inside values via
+ // mysqli_real_escape_string, so a raw CR in a dump is only ever
+ // a line terminator and is safe to rewrite.
+ $stream = str_replace(["\r\n", "\r"], "\n", $stream);
+
// Check file header
if ($start) {
if (
!str_starts_with($stream, "-- lwt-backup-")
&& !str_starts_with($stream, "-- lwt-exp_version-backup-")
) {
- $message = "Error: Invalid $title Restore file " .
+ $error = "Error: Invalid $title Restore file " .
"(possibly not created by LWT backup)";
- $install_status["errors"] = 1;
- $hasErrors = true;
break;
}
$start = false;
@@ -164,7 +151,7 @@ public static function restoreFile($handle, string $title, bool $validateSql = t
// Add stream to accumulator
$curr_content .= $stream;
// Get queries
- $queries = explode(';' . PHP_EOL, $curr_content);
+ $queries = explode(";\n", $curr_content);
// Replace line by remainders of the last element (incomplete line)
$curr_content = array_pop($queries);
@@ -173,12 +160,81 @@ public static function restoreFile($handle, string $title, bool $validateSql = t
}
}
- if (!feof($handle) && !$hasErrors) {
- $message = "Error: cannot read the end of the demo file!";
+ $reachedEof = feof($handle);
+ fclose($handle);
+
+ if ($error !== null) {
+ return ['queries' => [], 'error' => $error];
+ }
+
+ if (!$reachedEof) {
+ return [
+ 'queries' => [],
+ 'error' => "Error: cannot read the end of the $title file!",
+ ];
+ }
+
+ // Flush a trailing statement that is not followed by a newline, so a
+ // dump whose final line lacks its EOL still restores completely.
+ $tail = trim(rtrim(trim($curr_content), ';'));
+ if ($tail !== '') {
+ $queries_list[] = $tail;
+ }
+
+ return ['queries' => $queries_list, 'error' => null];
+ }
+
+ /**
+ * Restore the database from a file.
+ *
+ * @param resource $handle Backup file handle
+ * @param string $title File title
+ * @param bool $validateSql Whether to validate SQL statements (default true)
+ *
+ * @return string Human-readable status message
+ *
+ * @since 2.0.3-fork Function was broken
+ * @since 2.5.3-fork Function repaired
+ * @since 2.7.0-fork $handle should be an *uncompressed* file.
+ * @since 2.9.1-fork It can read SQL with more or less than one instruction a line
+ * @since 3.0.0 Added SQL validation for security hardening
+ * @since 3.2.2-fork Refuses to drop anything when the dump yields no
+ * statements; replays with FK checks suspended so FK-carrying
+ * CREATE TABLEs no longer fail on dependency order
+ */
+ public static function restoreFile($handle, string $title, bool $validateSql = true): string
+ {
+ // Multi-user safety guard: the rest of this function drops every
+ // LWT table and replaces it with the dump's contents, which would
+ // wipe every other user's data. Refuse the restore when more
+ // than one active user exists. The admin must take the multi-
+ // user mode down first if they really mean it (or use the
+ // per-user "Empty Database" + manual backup/restore recipe in
+ // docs).
+ $contextError = self::validateMultiUserRestoreContext();
+ if ($contextError !== null) {
+ fclose($handle);
+ return $contextError;
+ }
+
+ $message = "";
+ $hasErrors = false;
+ $install_status = [
+ "queries" => 0,
+ "successes" => 0,
+ "errors" => 0,
+ "drops" => 0,
+ "inserts" => 0,
+ "creates" => 0
+ ];
+
+ $parsed = self::parseBackupStream($handle, $title);
+ $queries_list = $parsed['queries'];
+ if ($parsed['error'] !== null) {
+ $message = $parsed['error'];
$install_status["errors"] = 1;
$hasErrors = true;
}
- fclose($handle);
// Validate all queries before executing any (security hardening)
if ($validateSql && !$hasErrors) {
@@ -196,6 +252,18 @@ public static function restoreFile($handle, string $title, bool $validateSql = t
}
}
+ // A dump that yielded no statements must never reach
+ // dropAllLwtTables(): dropping every table and then replaying nothing
+ // leaves the install empty with no way back. That is exactly what the
+ // PHP_EOL statement split did on Windows (issue #249) — silently, and
+ // while reporting success. Refuse instead, so any future parser bug
+ // costs an error message rather than the user's data.
+ if (!$hasErrors && $queries_list === []) {
+ $message = "Error: $title Restore file contains no SQL statements";
+ $install_status["errors"] = 1;
+ $hasErrors = true;
+ }
+
// Drop all existing tables first to ensure a clean slate
// This prevents issues with partial state from previous attempts
if (!$hasErrors) {
@@ -205,32 +273,46 @@ public static function restoreFile($handle, string $title, bool $validateSql = t
// Now run all queries
$connection = Globals::getDbConnection();
if (!$hasErrors && $connection !== null) {
- foreach ($queries_list as $query) {
- $sql_line = trim(
- str_replace("\r", "", str_replace("\n", "", $query))
- );
- if ($sql_line != "") {
- if (!str_starts_with($query, '-- ')) {
- $res = mysqli_query(
- $connection,
- $query
- );
- $install_status["queries"]++;
- if ($res == false) {
- $install_status["errors"]++;
- $hasErrors = true;
- } else {
- $install_status["successes"]++;
- if (str_starts_with($query, "INSERT INTO")) {
- $install_status["inserts"]++;
- } elseif (str_starts_with($query, "DROP TABLE")) {
- $install_status["drops"]++;
- } elseif (str_starts_with($query, "CREATE TABLE")) {
- $install_status["creates"]++;
+ // A dump's CREATE TABLE statements are taken from SHOW CREATE
+ // TABLE, so they carry their FOREIGN KEY clauses — but they are
+ // emitted in backup-table order, not dependency order (feed_links
+ // references news_feeds yet is created before it). With FK checks
+ // on, every such CREATE fails with errno 1215 and the restore
+ // leaves the install in pieces. dropAllLwtTables() restores checks
+ // to 1 on the way out, so they have to be suspended again here for
+ // the replay; the constraints are enforced again afterwards, once
+ // every table exists.
+ Connection::execute("SET FOREIGN_KEY_CHECKS = 0");
+ try {
+ foreach ($queries_list as $query) {
+ $sql_line = trim(
+ str_replace("\r", "", str_replace("\n", "", $query))
+ );
+ if ($sql_line != "") {
+ if (!str_starts_with($query, '-- ')) {
+ $res = mysqli_query(
+ $connection,
+ $query
+ );
+ $install_status["queries"]++;
+ if ($res == false) {
+ $install_status["errors"]++;
+ $hasErrors = true;
+ } else {
+ $install_status["successes"]++;
+ if (str_starts_with($query, "INSERT INTO")) {
+ $install_status["inserts"]++;
+ } elseif (str_starts_with($query, "DROP TABLE")) {
+ $install_status["drops"]++;
+ } elseif (str_starts_with($query, "CREATE TABLE")) {
+ $install_status["creates"]++;
+ }
}
}
}
}
+ } finally {
+ Connection::execute("SET FOREIGN_KEY_CHECKS = 1");
}
}
diff --git a/src/frontend/js/modules/admin/pages/backup_manager.ts b/src/frontend/js/modules/admin/pages/backup_manager.ts
index f85b7276e..af978c34d 100644
--- a/src/frontend/js/modules/admin/pages/backup_manager.ts
+++ b/src/frontend/js/modules/admin/pages/backup_manager.ts
@@ -16,6 +16,7 @@ interface BackupManagerState {
restoring: boolean;
emptying: boolean;
confirmEmpty: boolean;
+ selectFile(event: Event): void;
}
/**
@@ -27,7 +28,22 @@ export function backupManager(): BackupManagerState {
fileName: '',
restoring: false,
emptying: false,
- confirmEmpty: false
+ confirmEmpty: false,
+
+ /**
+ * Record the chosen backup file's name from a file-input change event.
+ *
+ * This lives here rather than inline in the view because @alpinejs/csp
+ * cannot parse optional chaining: the previous inline handler,
+ * `fileName = $event.target.files[0]?.name || ''`, threw a CSP parser
+ * error on every change, so the filename never appeared and the submit
+ * button stayed disabled forever (issue #249).
+ */
+ selectFile(event: Event): void {
+ const input = event.target as HTMLInputElement | null;
+ const file = input && input.files ? input.files[0] : null;
+ this.fileName = file ? file.name : '';
+ }
};
}
diff --git a/tests/backend/Modules/Admin/UseCases/AdminUseCaseTest.php b/tests/backend/Modules/Admin/UseCases/AdminUseCaseTest.php
index d893371f9..15ff5cb6c 100644
--- a/tests/backend/Modules/Admin/UseCases/AdminUseCaseTest.php
+++ b/tests/backend/Modules/Admin/UseCases/AdminUseCaseTest.php
@@ -375,6 +375,72 @@ public function testRestoreFromUploadSurfacesErrorPrefixedMessageAsFailure(): vo
Globals::setBackupRestoreEnabled(null);
}
+ public function testRestoreFromUploadCarriesTheRestoreReportOnSuccess(): void
+ {
+ // The controller shows this message verbatim. Dropping it in favour of
+ // a flat "Database restored" made a restore that replayed zero
+ // statements indistinguishable from a real one (issue #249), so the
+ // counts have to survive the use case.
+ Globals::setBackupRestoreEnabled(true);
+
+ $tmpFile = tempnam(sys_get_temp_dir(), 'lwt_test_');
+ $gz = gzopen($tmpFile, 'w');
+ gzwrite($gz, "-- lwt-backup-2026-07-08.sql.gz\n");
+ gzclose($gz);
+
+ $report = 'Success: Database restored - 412 queries - 412 successful '
+ . '(37/37 tables dropped/created, 340 records added), 0 failed.';
+ $repo = $this->createMock(BackupRepositoryInterface::class);
+ $repo->method('restoreFromHandle')->willReturn($report);
+
+ $useCase = new RestoreFromUpload($repo);
+ $result = $useCase->execute([
+ 'name' => 'backup.sql.gz',
+ 'type' => 'application/gzip',
+ 'tmp_name' => $tmpFile,
+ 'error' => 0,
+ 'size' => filesize($tmpFile),
+ ]);
+
+ $this->assertTrue($result['success']);
+ $this->assertSame($report, $result['message']);
+
+ @unlink($tmpFile);
+ Globals::setBackupRestoreEnabled(null);
+ }
+
+ public function testRestoreFromUploadSurfacesTheNoStatementsRefusalAsFailure(): void
+ {
+ // Restore::restoreFile refuses a dump that parsed to zero statements
+ // rather than dropping every table and replaying nothing (issue #249).
+ // The refusal must reach the admin as a failure.
+ Globals::setBackupRestoreEnabled(true);
+
+ $tmpFile = tempnam(sys_get_temp_dir(), 'lwt_test_');
+ $gz = gzopen($tmpFile, 'w');
+ gzwrite($gz, "-- lwt-backup-2026-07-08.sql.gz\n");
+ gzclose($gz);
+
+ $repo = $this->createMock(BackupRepositoryInterface::class);
+ $repo->method('restoreFromHandle')
+ ->willReturn('Error: Database Restore file contains no SQL statements');
+
+ $useCase = new RestoreFromUpload($repo);
+ $result = $useCase->execute([
+ 'name' => 'backup.sql.gz',
+ 'type' => 'application/gzip',
+ 'tmp_name' => $tmpFile,
+ 'error' => 0,
+ 'size' => filesize($tmpFile),
+ ]);
+
+ $this->assertFalse($result['success']);
+ $this->assertStringContainsString('no SQL statements', $result['error']);
+
+ @unlink($tmpFile);
+ Globals::setBackupRestoreEnabled(null);
+ }
+
public function testRestoreFromUploadDisabledMessageMentionsEnvSetting(): void
{
Globals::setBackupRestoreEnabled(false);
diff --git a/tests/backend/Shared/Infrastructure/Database/RestoreParseBackupStreamTest.php b/tests/backend/Shared/Infrastructure/Database/RestoreParseBackupStreamTest.php
new file mode 100644
index 000000000..cab1c4543
--- /dev/null
+++ b/tests/backend/Shared/Infrastructure/Database/RestoreParseBackupStreamTest.php
@@ -0,0 +1,203 @@
+
+ * @since 3.2.2-fork
+ */
+
+declare(strict_types=1);
+
+namespace Tests\Backend\Shared\Infrastructure\Database;
+
+use Lwt\Shared\Infrastructure\Database\Restore;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\TestCase;
+use ReflectionMethod;
+
+/**
+ * Unit tests for Restore::parseBackupStream().
+ *
+ * These exercise the parser in isolation, so they need no database.
+ *
+ * @since 3.2.2-fork
+ */
+#[CoversClass(Restore::class)]
+class RestoreParseBackupStreamTest extends TestCase
+{
+ /**
+ * The header line every LWT dump starts with.
+ */
+ private const HEADER = "-- lwt-backup-2026-07-08-11-20-52.sql.gz";
+
+ /**
+ * Call the private parser with the given dump contents.
+ *
+ * @param string $contents Raw dump bytes
+ *
+ * @return array{queries: list, error: string|null}
+ */
+ private function parse(string $contents): array
+ {
+ $handle = fopen('php://memory', 'r+');
+ $this->assertIsResource($handle);
+ fwrite($handle, $contents);
+ rewind($handle);
+
+ // Private methods are reflection-accessible without setAccessible()
+ // since PHP 8.1, and the call is deprecated as of 8.5.
+ $method = new ReflectionMethod(Restore::class, 'parseBackupStream');
+
+ /** @var array{queries: list, error: string|null} $result */
+ $result = $method->invoke(null, $handle, 'Database');
+ return $result;
+ }
+
+ /**
+ * Build a dump body with the given line terminator.
+ *
+ * @param string $eol Line terminator to join with
+ *
+ * @return string
+ */
+ private function dump(string $eol): string
+ {
+ return implode($eol, [
+ self::HEADER,
+ '',
+ 'DROP TABLE IF EXISTS words;',
+ "CREATE TABLE words (WoID int, WoText varchar(250));",
+ "INSERT INTO words VALUES(1,'Haus');",
+ "INSERT INTO words VALUES(2,'Baum');",
+ '',
+ ]);
+ }
+
+ #[Test]
+ public function parsesAnLfDump(): void
+ {
+ $result = $this->parse($this->dump("\n"));
+
+ $this->assertNull($result['error']);
+ $this->assertCount(4, $result['queries']);
+ $this->assertSame('DROP TABLE IF EXISTS words', $result['queries'][0]);
+ $this->assertSame("INSERT INTO words VALUES(2,'Baum')", $result['queries'][3]);
+ }
+
+ /**
+ * The #249 regression: a CRLF dump, or an LF dump read on a Windows host,
+ * must produce the same statements as an LF dump on Linux.
+ */
+ #[Test]
+ public function parsesACrlfDumpIdenticallyToAnLfDump(): void
+ {
+ $lf = $this->parse($this->dump("\n"));
+ $crlf = $this->parse($this->dump("\r\n"));
+
+ $this->assertNull($crlf['error']);
+ $this->assertSame($lf['queries'], $crlf['queries']);
+ $this->assertNotEmpty($crlf['queries']);
+ }
+
+ #[Test]
+ public function neverReturnsAnEmptyStatementListForAValidDump(): void
+ {
+ foreach (["\n", "\r\n"] as $eol) {
+ $result = $this->parse($this->dump($eol));
+ $this->assertNotSame(
+ [],
+ $result['queries'],
+ 'A valid dump must never parse to zero statements: the caller '
+ . 'would drop every table and restore nothing.'
+ );
+ }
+ }
+
+ #[Test]
+ public function keepsAFinalStatementThatLacksATrailingNewline(): void
+ {
+ $contents = self::HEADER . "\n"
+ . "INSERT INTO words VALUES(1,'Haus');\n"
+ . "INSERT INTO words VALUES(2,'Baum');";
+
+ $result = $this->parse($contents);
+
+ $this->assertNull($result['error']);
+ $this->assertCount(2, $result['queries']);
+ $this->assertSame("INSERT INTO words VALUES(2,'Baum')", $result['queries'][1]);
+ }
+
+ #[Test]
+ public function joinsAStatementSpanningSeveralLines(): void
+ {
+ $contents = self::HEADER . "\n"
+ . "CREATE TABLE words (\n"
+ . " WoID int,\n"
+ . " WoText varchar(250)\n"
+ . ");\n";
+
+ $result = $this->parse($contents);
+
+ $this->assertNull($result['error']);
+ $this->assertCount(1, $result['queries']);
+ $this->assertStringContainsString('WoText varchar(250)', $result['queries'][0]);
+ }
+
+ #[Test]
+ public function skipsCommentLines(): void
+ {
+ $contents = self::HEADER . "\n"
+ . "-- a comment\n"
+ . "--\n"
+ . "INSERT INTO words VALUES(1,'Haus');\n";
+
+ $result = $this->parse($contents);
+
+ $this->assertNull($result['error']);
+ $this->assertSame(["INSERT INTO words VALUES(1,'Haus')"], $result['queries']);
+ }
+
+ #[Test]
+ public function acceptsTheExpVersionHeaderVariant(): void
+ {
+ $contents = "-- lwt-exp_version-backup-2026-07-08.sql.gz\n"
+ . "INSERT INTO words VALUES(1,'Haus');\n";
+
+ $result = $this->parse($contents);
+
+ $this->assertNull($result['error']);
+ $this->assertCount(1, $result['queries']);
+ }
+
+ #[Test]
+ public function rejectsAFileWithoutAnLwtHeader(): void
+ {
+ $result = $this->parse("-- some other dump\nINSERT INTO words VALUES(1,'x');\n");
+
+ $this->assertNotNull($result['error']);
+ $this->assertStringContainsString('possibly not created by LWT backup', $result['error']);
+ $this->assertSame([], $result['queries']);
+ }
+
+ #[Test]
+ public function rejectsAHeaderOnlyFileWithNoStatements(): void
+ {
+ $result = $this->parse(self::HEADER . "\n");
+
+ // The parser itself reports no error here; restoreFile() turns the
+ // empty list into a refusal so nothing is ever dropped.
+ $this->assertNull($result['error']);
+ $this->assertSame([], $result['queries']);
+ }
+}
diff --git a/tests/frontend/admin/backup_manager.test.ts b/tests/frontend/admin/backup_manager.test.ts
index 0fd3cc99a..9eec34a05 100644
--- a/tests/frontend/admin/backup_manager.test.ts
+++ b/tests/frontend/admin/backup_manager.test.ts
@@ -51,6 +51,7 @@ describe('backup_manager.ts', () => {
expect(state).toHaveProperty('restoring');
expect(state).toHaveProperty('emptying');
expect(state).toHaveProperty('confirmEmpty');
+ expect(state).toHaveProperty('selectFile');
});
it('returns a fresh state on each call', () => {
@@ -65,6 +66,100 @@ describe('backup_manager.ts', () => {
});
});
+ // ===========================================================================
+ // selectFile Tests (issue #249)
+ // ===========================================================================
+
+ describe('selectFile', () => {
+ /**
+ * Build a file input whose `files` list holds the given names, the way a
+ * browser presents it to a change handler.
+ */
+ function inputWithFiles(names: string[]): HTMLInputElement {
+ const input = document.createElement('input');
+ input.type = 'file';
+ Object.defineProperty(input, 'files', {
+ value: names.map((name) => new File(['x'], name)),
+ configurable: true
+ });
+ document.body.appendChild(input);
+ return input;
+ }
+
+ it('records the chosen file name', () => {
+ const state = backupManager();
+ const input = inputWithFiles(['LWT 7-8-26.gz']);
+
+ state.selectFile({ target: input } as unknown as Event);
+
+ expect(state.fileName).toBe('LWT 7-8-26.gz');
+ });
+
+ it('clears the name when the selection is emptied', () => {
+ const state = backupManager();
+ state.fileName = 'previous.sql';
+ const input = inputWithFiles([]);
+
+ state.selectFile({ target: input } as unknown as Event);
+
+ expect(state.fileName).toBe('');
+ });
+
+ it('handles an event with no target without throwing', () => {
+ const state = backupManager();
+
+ expect(() => state.selectFile({ target: null } as unknown as Event)).not.toThrow();
+ expect(state.fileName).toBe('');
+ });
+
+ it('handles an input with a null files list', () => {
+ const state = backupManager();
+ const input = document.createElement('input');
+ input.type = 'file';
+ Object.defineProperty(input, 'files', { value: null, configurable: true });
+
+ state.selectFile({ target: input } as unknown as Event);
+
+ expect(state.fileName).toBe('');
+ });
+
+ it('is evaluable by the CSP Alpine build, unlike an inline optional chain', async () => {
+ // The regression this guards: `@change="fileName = $event.target
+ // .files[0]?.name || ''"` throws "CSP Parser Error: Unexpected token"
+ // in @alpinejs/csp, so the filename never appeared and the restore
+ // button stayed disabled. A method call parses fine.
+ const Alpine = (await import('alpinejs')).default;
+ const captured: string[] = [];
+ const origWarn = console.warn;
+ const origError = console.error;
+ console.warn = (...a: unknown[]) => { captured.push(a.map(String).join(' ')); };
+ console.error = (...a: unknown[]) => { captured.push(a.map(String).join(' ')); };
+
+ Alpine.data('backupManagerCspCheck', backupManager);
+ document.body.innerHTML = `
+
+
+
+
`;
+ Alpine.start();
+ await new Promise((resolve) => setTimeout(resolve, 30));
+
+ const input = document.getElementById('csp-file') as HTMLInputElement;
+ Object.defineProperty(input, 'files', {
+ value: [new File(['x'], 'backup.sql.gz')],
+ configurable: true
+ });
+ input.dispatchEvent(new Event('change'));
+ await new Promise((resolve) => setTimeout(resolve, 30));
+
+ console.warn = origWarn;
+ console.error = origError;
+
+ expect(captured.join('\n')).not.toContain('CSP Parser Error');
+ expect(document.getElementById('csp-name')?.textContent).toBe('backup.sql.gz');
+ });
+ });
+
// ===========================================================================
// initBackupManagerAlpine Tests
// ===========================================================================