]> BookStack Code Mirror - bookstack/commitdiff
ZIP Imports: Added API test cases
authorDan Brown <redacted>
Fri, 18 Jul 2025 13:05:32 +0000 (14:05 +0100)
committerDan Brown <redacted>
Fri, 18 Jul 2025 13:05:32 +0000 (14:05 +0100)
app/Exports/Controllers/ImportApiController.php
app/Exports/Import.php
app/Exports/ImportRepo.php
app/Http/ApiController.php
database/factories/Exports/ImportFactory.php
routes/api.php
tests/Api/ImportsApiTest.php [new file with mode: 0644]

index 13bc9d83ea5e027ab03260103ef502006cac2695..0749ff9330ac8f60cdc992e7a6eb8576c29cd663 100644 (file)
@@ -26,9 +26,11 @@ class ImportApiController extends ApiController
      */
     public function list(): JsonResponse
     {
-        $imports = $this->imports->getVisibleImports()->all();
+        $query = $this->imports->queryVisible();
 
-        return response()->json($imports);
+        return $this->apiListingResponse($query, [
+            'id', 'name', 'size', 'type', 'created_by', 'created_at', 'updated_at'
+        ]);
     }
 
     /**
@@ -44,7 +46,7 @@ class ImportApiController extends ApiController
         try {
             $import = $this->imports->storeFromUpload($file);
         } catch (ZipValidationException $exception) {
-            $message = "ZIP upload failed with the following validation errors: \n" . implode("\n", $exception->errors);
+            $message = "ZIP upload failed with the following validation errors: \n" . $this->formatErrors($exception->errors);
             return $this->jsonError($message, 422);
         }
 
@@ -53,11 +55,15 @@ class ImportApiController extends ApiController
 
     /**
      * Read details of a pending ZIP import.
+     * The "details" property contains high-level metadata regarding the ZIP import content,
+     * and the structure of this will change depending on import "type".
      */
     public function read(int $id): JsonResponse
     {
         $import = $this->imports->findVisible($id);
 
+        $import->setAttribute('details', $import->decodeMetadata());
+
         return response()->json($import);
     }
 
@@ -82,7 +88,7 @@ class ImportApiController extends ApiController
         try {
             $entity = $this->imports->runImport($import, $parent);
         } catch (ZipImportException $exception) {
-            $message = "ZIP import failed with the following errors: \n" . implode("\n", $exception->errors);
+            $message = "ZIP import failed with the following errors: \n" . $this->formatErrors($exception->errors);
             return $this->jsonError($message);
         }
 
@@ -112,4 +118,17 @@ class ImportApiController extends ApiController
             ],
         ];
     }
+
+    protected function formatErrors(array $errors): string
+    {
+        $parts = [];
+        foreach ($errors as $key => $error) {
+            if (is_string($key)) {
+                $parts[] = "[{$key}] {$error}";
+            } else {
+                $parts[] = $error;
+            }
+        }
+        return implode("\n", $parts);
+    }
 }
index 9c1771c468f27e27d94d48c75bc8de67386490b8..ca4f529815f02e0912639945e76d8442c14dfb79 100644 (file)
@@ -28,6 +28,8 @@ class Import extends Model implements Loggable
 {
     use HasFactory;
 
+    protected $hidden = ['metadata'];
+
     public function getSizeString(): string
     {
         $mb = round($this->size / 1000000, 2);
index f72386c47bc949848e6895922fc8c1a91e0d9b10..e030a88d261f2745d96ee6bd97e7291d783d7d69 100644 (file)
@@ -17,6 +17,7 @@ use BookStack\Exports\ZipExports\ZipExportValidator;
 use BookStack\Exports\ZipExports\ZipImportRunner;
 use BookStack\Facades\Activity;
 use BookStack\Uploads\FileStorage;
+use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Support\Facades\DB;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
@@ -34,6 +35,11 @@ class ImportRepo
      * @return Collection<Import>
      */
     public function getVisibleImports(): Collection
+    {
+        return $this->queryVisible()->get();
+    }
+
+    public function queryVisible(): Builder
     {
         $query = Import::query();
 
@@ -41,7 +47,7 @@ class ImportRepo
             $query->where('created_by', user()->id);
         }
 
-        return $query->get();
+        return $query;
     }
 
     public function findVisible(int $id): Import
index c0dbe2fca4afb0e42ede3153eeea1603f4f15c97..1a92afa33801eebc4ef9bff5301caa9c5f606447 100644 (file)
@@ -8,7 +8,7 @@ use Illuminate\Http\JsonResponse;
 
 abstract class ApiController extends Controller
 {
-    protected $rules = [];
+    protected array $rules = [];
 
     /**
      * Provide a paginated listing JSON response in a standard format
index 5d0b4f892997d82001e883b0af666ad2ff267ecc..cdb019dd362c8420607cdef4e01b62ebfcf28a39 100644 (file)
@@ -24,6 +24,7 @@ class ImportFactory extends Factory
             'path' => 'uploads/files/imports/' . Str::random(10) . '.zip',
             'name' => $this->faker->words(3, true),
             'type' => 'book',
+            'size' => rand(1, 1001),
             'metadata' => '{"name": "My book"}',
             'created_at' => User::factory(),
         ];
index efb7b258c6fa78b75a34062b07c766ee8ebedea5..98af4bb2616e4b3481d2159fbeb2b1f9727efdc3 100644 (file)
@@ -88,11 +88,11 @@ Route::get('roles/{id}', [RoleApiController::class, 'read']);
 Route::put('roles/{id}', [RoleApiController::class, 'update']);
 Route::delete('roles/{id}', [RoleApiController::class, 'delete']);
 
-Route::get('import', [ExportControllers\ImportApiController::class, 'list']);
-Route::post('import', [ExportControllers\ImportApiController::class, 'upload']);
-Route::get('import/{id}', [ExportControllers\ImportApiController::class, 'read']);
-Route::post('import/{id}', [ExportControllers\ImportApiController::class, 'run']);
-Route::delete('import/{id}', [ExportControllers\ImportApiController::class, 'delete']);
+Route::get('imports', [ExportControllers\ImportApiController::class, 'list']);
+Route::post('imports', [ExportControllers\ImportApiController::class, 'upload']);
+Route::get('imports/{id}', [ExportControllers\ImportApiController::class, 'read']);
+Route::post('imports/{id}', [ExportControllers\ImportApiController::class, 'run']);
+Route::delete('imports/{id}', [ExportControllers\ImportApiController::class, 'delete']);
 
 Route::get('recycle-bin', [EntityControllers\RecycleBinApiController::class, 'list']);
 Route::put('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'restore']);
diff --git a/tests/Api/ImportsApiTest.php b/tests/Api/ImportsApiTest.php
new file mode 100644 (file)
index 0000000..5230343
--- /dev/null
@@ -0,0 +1,175 @@
+<?php
+
+namespace Api;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Exports\Import;
+use Tests\Api\TestsApi;
+use Tests\Exports\ZipTestHelper;
+use Tests\TestCase;
+
+class ImportsApiTest extends TestCase
+{
+    use TestsApi;
+
+    protected string $baseEndpoint = '/api/imports';
+
+    public function test_upload_and_run(): void
+    {
+        $book = $this->entities->book();
+        $zip = ZipTestHelper::zipUploadFromData([
+            'page' => [
+                'name' => 'My API import page',
+                'tags' => [
+                    [
+                        'name' => 'My api tag',
+                        'value' => 'api test value'
+                    ]
+                ],
+            ],
+        ]);
+
+        $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]);
+        $resp->assertStatus(200);
+
+        $importId = $resp->json('id');
+        $import = Import::query()->findOrFail($importId);
+        $this->assertEquals('page', $import->type);
+
+        $resp = $this->post($this->baseEndpoint . "/{$import->id}", [
+            'parent_type' => 'book',
+            'parent_id' => $book->id,
+        ]);
+        $resp->assertJson([
+            'name' => 'My API import page',
+            'book_id' => $book->id,
+        ]);
+
+        $page = Page::query()->where('name', '=', 'My API import page')->first();
+        $this->assertEquals('My api tag', $page->tags()->first()->name);
+    }
+
+    public function test_upload_validation_error(): void
+    {
+        $zip = ZipTestHelper::zipUploadFromData([
+            'page' => [
+                'tags' => [
+                    [
+                        'name' => 'My api tag',
+                        'value' => 'api test value'
+                    ]
+                ],
+            ],
+        ]);
+
+        $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]);
+        $resp->assertStatus(422);
+        $message = $resp->json('message');
+
+        $this->assertStringContainsString('ZIP upload failed with the following validation errors:', $message);
+        $this->assertStringContainsString('[page.name] The name field is required.', $message);
+    }
+
+    public function test_list(): void
+    {
+        $imports = Import::factory()->count(10)->create();
+
+        $resp = $this->actingAsApiAdmin()->get($this->baseEndpoint);
+        $resp->assertJsonCount(10, 'data');
+        $resp->assertJsonPath('total', 10);
+
+        $firstImport = $imports->first();
+        $resp = $this->actingAsApiAdmin()->get($this->baseEndpoint . '?filter[id]=' . $firstImport->id);
+        $resp->assertJsonCount(1, 'data');
+        $resp->assertJsonPath('data.0.id', $firstImport->id);
+        $resp->assertJsonPath('data.0.name', $firstImport->name);
+        $resp->assertJsonPath('data.0.size', $firstImport->size);
+        $resp->assertJsonPath('data.0.type', $firstImport->type);
+    }
+
+    public function test_list_visibility_limited(): void
+    {
+        $user = $this->users->editor();
+        $admin = $this->users->admin();
+        $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]);
+        $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]);
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        $resp = $this->actingAsForApi($user)->get($this->baseEndpoint);
+        $resp->assertJsonCount(1, 'data');
+        $resp->assertJsonPath('data.0.name', 'MySuperUserImport');
+
+        $this->permissions->grantUserRolePermissions($user, ['settings-manage']);
+
+        $resp = $this->actingAsForApi($user)->get($this->baseEndpoint);
+        $resp->assertJsonCount(2, 'data');
+        $resp->assertJsonPath('data.1.name', 'MySuperAdminImport');
+    }
+
+    public function test_read(): void
+    {
+        $zip = ZipTestHelper::zipUploadFromData([
+            'book' => [
+                'name' => 'My API import book',
+                'pages' => [
+                    [
+                        'name' => 'My import page',
+                        'tags' => [
+                            [
+                                'name' => 'My api tag',
+                                'value' => 'api test value'
+                            ]
+                        ]
+                    ]
+                ],
+            ],
+        ]);
+
+        $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]);
+        $resp->assertStatus(200);
+
+        $resp = $this->get($this->baseEndpoint . "/{$resp->json('id')}");
+        $resp->assertStatus(200);
+
+        $resp->assertJsonPath('details.name', 'My API import book');
+        $resp->assertJsonPath('details.pages.0.name', 'My import page');
+        $resp->assertJsonPath('details.pages.0.tags.0.name', 'My api tag');
+        $resp->assertJsonMissingPath('metadata');
+    }
+
+    public function test_delete(): void
+    {
+        $import = Import::factory()->create();
+
+        $resp = $this->actingAsApiAdmin()->delete($this->baseEndpoint . "/{$import->id}");
+        $resp->assertStatus(204);
+    }
+
+    public function test_content_import_permissions_needed(): void
+    {
+        $user = $this->users->viewer();
+        $this->permissions->grantUserRolePermissions($user, ['access-api']);
+        $this->actingAsForApi($user);
+        $requests = [
+             ['GET', $this->baseEndpoint],
+             ['POST', $this->baseEndpoint],
+             ['GET', $this->baseEndpoint . "/1"],
+             ['POST', $this->baseEndpoint . "/1"],
+             ['DELETE', $this->baseEndpoint . "/1"],
+        ];
+
+        foreach ($requests as $request) {
+            [$method, $endpoint] = $request;
+            $resp = $this->json($method, $endpoint);
+            $resp->assertStatus(403);
+        }
+
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        foreach ($requests as $request) {
+            [$method, $endpoint] = $request;
+            $resp = $this->call($method, $endpoint);
+            $this->assertNotEquals(403, $resp->status(), "A {$method} request to {$endpoint} returned 403");
+        }
+    }
+}