]> BookStack Code Mirror - bookstack/commitdiff
ZIP Imports: Added validation message display, added testing
authorDan Brown <redacted>
Sat, 2 Nov 2024 14:51:04 +0000 (14:51 +0000)
committerDan Brown <redacted>
Sat, 2 Nov 2024 14:51:04 +0000 (14:51 +0000)
Testing covers main UI access, and main non-successfull import actions.
Started planning stored import model.
Extracted some text to language files.

app/Exports/Controllers/ImportController.php
app/Exports/ZipExports/ZipExportValidator.php
lang/en/entities.php
lang/en/errors.php
lang/en/validation.php
resources/views/exports/import.blade.php
tests/Exports/ZipImportTest.php [new file with mode: 0644]

index 5885f7991cd014211e71600b879686db3fbc3c63..323ecef268f1700e2dd37f5d46949a03af5540d5 100644 (file)
@@ -17,7 +17,9 @@ class ImportController extends Controller
     {
         // TODO - Show existing imports for user (or for all users if admin-level user)
 
-        return view('exports.import');
+        return view('exports.import', [
+            'zipErrors' => session()->pull('validation_errors') ?? [],
+        ]);
     }
 
     public function upload(Request $request)
@@ -31,13 +33,21 @@ class ImportController extends Controller
 
         $errors = (new ZipExportValidator($zipPath))->validate();
         if ($errors) {
-            dd($errors);
+            session()->flash('validation_errors', $errors);
+            return redirect('/import');
         }
+
         dd('passed');
-        // TODO - Read existing ZIP upload and send through validator
-            // TODO - If invalid, return user with errors
         // TODO - Upload to storage
-        // TODO - Store info/results from validator
+        // TODO - Store info/results for display:
+          // - zip_path
+          // - name (From name of thing being imported)
+          // - size
+          // - book_count
+          // - chapter_count
+          // - page_count
+          // - created_by
+          // - created_at/updated_at
         // TODO - Send user to next import stage
     }
 }
index e56394acaebfeff93945fa7d998aa3725c7b3a09..dd56f3e70a8e7d4b75583cfc35d5454dd3011edc 100644 (file)
@@ -18,21 +18,21 @@ class ZipExportValidator
     {
         // Validate file exists
         if (!file_exists($this->zipPath) || !is_readable($this->zipPath)) {
-            return ['format' => "Could not read ZIP file"];
+            return ['format' => trans('errors.import_zip_cant_read')];
         }
 
         // Validate file is valid zip
         $zip = new \ZipArchive();
         $opened = $zip->open($this->zipPath, ZipArchive::RDONLY);
         if ($opened !== true) {
-            return ['format' => "Could not read ZIP file"];
+            return ['format' => trans('errors.import_zip_cant_read')];
         }
 
         // Validate json data exists, including metadata
         $jsonData = $zip->getFromName('data.json') ?: '';
         $importData = json_decode($jsonData, true);
         if (!$importData) {
-            return ['format' => "Could not find and decode ZIP data.json content"];
+            return ['format' => trans('errors.import_zip_cant_decode_data')];
         }
 
         $helper = new ZipValidationHelper($zip);
@@ -47,9 +47,10 @@ class ZipExportValidator
             $modelErrors = ZipExportPage::validate($helper, $importData['page']);
             $keyPrefix = 'page';
         } else {
-            return ['format' => "ZIP file has no book, chapter or page data"];
+            return ['format' => trans('errors.import_zip_no_data')];
         }
 
+
         return $this->flattenModelErrors($modelErrors, $keyPrefix);
     }
 
index 45ca4cf6b313a07d502ff1f437f9e28f4e3abc39..1061473353324f73444533337f9a89f8e9e7044f 100644 (file)
@@ -45,6 +45,9 @@ return [
     'default_template_select' => 'Select a template page',
     'import' => 'Import',
     'import_validate' => 'Validate Import',
+    'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to import then press "Validate Import" to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.',
+    'import_zip_select' => 'Select ZIP file to upload',
+    'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:',
 
     // Permissions and restrictions
     'permissions' => 'Permissions',
index 9c40aa9ed339578a1585ddefda68760cd2563c8e..3f2f303311e36015ecc9d5eb48a1c576d8cb13fe 100644 (file)
@@ -105,6 +105,11 @@ return [
     'app_down' => ':appName is down right now',
     'back_soon' => 'It will be back up soon.',
 
+    // Import
+    'import_zip_cant_read' => 'Could not read ZIP file.',
+    'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.',
+    'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.',
+
     // API errors
     'api_no_authorization_found' => 'No authorization token found on the request',
     'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect',
index 9cf5d78b6f6f7f26fa9bc3496d4708609ce9c874..bc01ac47b948f53cdbe140c1fde43f3ca056498d 100644 (file)
@@ -106,7 +106,7 @@ return [
     'uploaded'             => 'The file could not be uploaded. The server may not accept files of this size.',
 
     'zip_file' => 'The :attribute needs to reference a file within the ZIP.',
-    'zip_model_expected' => 'Data object expected but ":type" found',
+    'zip_model_expected' => 'Data object expected but ":type" found.',
 
     // Custom validation lines
     'custom' => [
index 15f33e6b7c165099b6b324124aa2b073e63a1d51..c4d7c881845545f92fb78d2e52b8703ae4fee0a5 100644 (file)
@@ -9,14 +9,10 @@
             <form action="{{ url('/import') }}" enctype="multipart/form-data" method="POST">
                 {{ csrf_field() }}
                 <div class="flex-container-row justify-space-between wrap gap-x-xl gap-y-s">
-                    <p class="flex min-width-l text-muted mb-s">
-                        Import books, chapters & pages using a portable zip export from the same, or a different, instance.
-                        Select a ZIP file to import then press "Validate Import" to proceed.
-                        After the file has been uploaded and validated you'll be able to configure & confirm the import in the next view.
-                    </p>
+                    <p class="flex min-width-l text-muted mb-s">{{ trans('entities.import_desc') }}</p>
                     <div class="flex-none min-width-l flex-container-row justify-flex-end">
                         <div class="mb-m">
-                            <label for="file">Select ZIP file to upload</label>
+                            <label for="file">{{ trans('entities.import_zip_select') }}</label>
                             <input type="file"
                                    accept=".zip,application/zip,application/x-zip-compressed"
                                    name="file"
                     </div>
                 </div>
 
+                @if(count($zipErrors) > 0)
+                    <p class="mb-xs"><strong class="text-neg">{{ trans('entities.import_zip_validation_errors') }}</strong></p>
+                    <ul class="mb-m">
+                        @foreach($zipErrors as $key => $error)
+                            <li><strong class="text-neg">[{{ $key }}]</strong>: {{ $error }}</li>
+                        @endforeach
+                    </ul>
+                @endif
+
                 <div class="text-right">
                     <a href="{{ url('/books') }}" class="button outline">{{ trans('common.cancel') }}</a>
                     <button type="submit" class="button">{{ trans('entities.import_validate') }}</button>
diff --git a/tests/Exports/ZipImportTest.php b/tests/Exports/ZipImportTest.php
new file mode 100644 (file)
index 0000000..c9d255b
--- /dev/null
@@ -0,0 +1,124 @@
+<?php
+
+namespace Tests\Exports;
+
+use Illuminate\Http\UploadedFile;
+use Illuminate\Testing\TestResponse;
+use Tests\TestCase;
+use ZipArchive;
+
+class ZipImportTest extends TestCase
+{
+    public function test_import_page_view()
+    {
+        $resp = $this->asAdmin()->get('/import');
+        $resp->assertSee('Import');
+        $this->withHtml($resp)->assertElementExists('form input[type="file"][name="file"]');
+    }
+
+    public function test_permissions_needed_for_import_page()
+    {
+        $user = $this->users->viewer();
+        $this->actingAs($user);
+
+        $resp = $this->get('/books');
+        $this->withHtml($resp)->assertLinkNotExists(url('/import'));
+        $resp = $this->get('/import');
+        $resp->assertRedirect('/');
+
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        $resp = $this->get('/books');
+        $this->withHtml($resp)->assertLinkExists(url('/import'));
+        $resp = $this->get('/import');
+        $resp->assertOk();
+        $resp->assertSeeText('Select ZIP file to upload');
+    }
+
+    public function test_zip_read_errors_are_shown_on_validation()
+    {
+        $invalidUpload = $this->files->uploadedImage('image.zip');
+
+        $this->asAdmin();
+        $resp = $this->runImportFromFile($invalidUpload);
+        $resp->assertRedirect('/import');
+
+        $resp = $this->followRedirects($resp);
+        $resp->assertSeeText('Could not read ZIP file');
+    }
+
+    public function test_error_shown_if_missing_data()
+    {
+        $zipFile = tempnam(sys_get_temp_dir(), 'bstest-');
+        $zip = new ZipArchive();
+        $zip->open($zipFile, ZipArchive::CREATE);
+        $zip->addFromString('beans', 'cat');
+        $zip->close();
+
+        $this->asAdmin();
+        $upload = new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true);
+        $resp = $this->runImportFromFile($upload);
+        $resp->assertRedirect('/import');
+
+        $resp = $this->followRedirects($resp);
+        $resp->assertSeeText('Could not find and decode ZIP data.json content.');
+    }
+
+    public function test_error_shown_if_no_importable_key()
+    {
+        $this->asAdmin();
+        $resp = $this->runImportFromFile($this->zipUploadFromData([
+            'instance' => []
+        ]));
+
+        $resp->assertRedirect('/import');
+        $resp = $this->followRedirects($resp);
+        $resp->assertSeeText('ZIP file data has no expected book, chapter or page content.');
+    }
+
+    public function test_zip_data_validation_messages_shown()
+    {
+        $this->asAdmin();
+        $resp = $this->runImportFromFile($this->zipUploadFromData([
+            'book' => [
+                'id' => 4,
+                'pages' => [
+                    'cat',
+                    [
+                        'name' => 'My inner page',
+                        'tags' => [
+                            [
+                                'value' => 5
+                            ]
+                        ],
+                    ]
+                ],
+            ]
+        ]));
+
+        $resp->assertRedirect('/import');
+        $resp = $this->followRedirects($resp);
+
+        $resp->assertSeeText('[book.name]: The name field is required.');
+        $resp->assertSeeText('[book.pages.0.0]: Data object expected but "string" found.');
+        $resp->assertSeeText('[book.pages.1.tags.0.name]: The name field is required.');
+        $resp->assertSeeText('[book.pages.1.tags.0.value]: The value must be a string.');
+    }
+
+    protected function runImportFromFile(UploadedFile $file): TestResponse
+    {
+        return $this->call('POST', '/import', [], [], ['file' => $file]);
+    }
+
+    protected function zipUploadFromData(array $data): UploadedFile
+    {
+        $zipFile = tempnam(sys_get_temp_dir(), 'bstest-');
+
+        $zip = new ZipArchive();
+        $zip->open($zipFile, ZipArchive::CREATE);
+        $zip->addFromString('data.json', json_encode($data));
+        $zip->close();
+
+        return new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true);
+    }
+}