Skip to content

Commit 7302571

Browse files
committed
ZIP Imports: Added API test cases
1 parent d556845 commit 7302571

File tree

7 files changed

+214
-11
lines changed

7 files changed

+214
-11
lines changed

app/Exports/Controllers/ImportApiController.php

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ public function __construct(
2626
*/
2727
public function list(): JsonResponse
2828
{
29-
$imports = $this->imports->getVisibleImports()->all();
29+
$query = $this->imports->queryVisible();
3030

31-
return response()->json($imports);
31+
return $this->apiListingResponse($query, [
32+
'id', 'name', 'size', 'type', 'created_by', 'created_at', 'updated_at'
33+
]);
3234
}
3335

3436
/**
@@ -44,7 +46,7 @@ public function upload(Request $request): JsonResponse
4446
try {
4547
$import = $this->imports->storeFromUpload($file);
4648
} catch (ZipValidationException $exception) {
47-
$message = "ZIP upload failed with the following validation errors: \n" . implode("\n", $exception->errors);
49+
$message = "ZIP upload failed with the following validation errors: \n" . $this->formatErrors($exception->errors);
4850
return $this->jsonError($message, 422);
4951
}
5052

@@ -53,11 +55,15 @@ public function upload(Request $request): JsonResponse
5355

5456
/**
5557
* Read details of a pending ZIP import.
58+
* The "details" property contains high-level metadata regarding the ZIP import content,
59+
* and the structure of this will change depending on import "type".
5660
*/
5761
public function read(int $id): JsonResponse
5862
{
5963
$import = $this->imports->findVisible($id);
6064

65+
$import->setAttribute('details', $import->decodeMetadata());
66+
6167
return response()->json($import);
6268
}
6369

@@ -82,7 +88,7 @@ public function run(int $id, Request $request): JsonResponse
8288
try {
8389
$entity = $this->imports->runImport($import, $parent);
8490
} catch (ZipImportException $exception) {
85-
$message = "ZIP import failed with the following errors: \n" . implode("\n", $exception->errors);
91+
$message = "ZIP import failed with the following errors: \n" . $this->formatErrors($exception->errors);
8692
return $this->jsonError($message);
8793
}
8894

@@ -112,4 +118,17 @@ protected function rules(): array
112118
],
113119
];
114120
}
121+
122+
protected function formatErrors(array $errors): string
123+
{
124+
$parts = [];
125+
foreach ($errors as $key => $error) {
126+
if (is_string($key)) {
127+
$parts[] = "[{$key}] {$error}";
128+
} else {
129+
$parts[] = $error;
130+
}
131+
}
132+
return implode("\n", $parts);
133+
}
115134
}

app/Exports/Import.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ class Import extends Model implements Loggable
2828
{
2929
use HasFactory;
3030

31+
protected $hidden = ['metadata'];
32+
3133
public function getSizeString(): string
3234
{
3335
$mb = round($this->size / 1000000, 2);

app/Exports/ImportRepo.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use BookStack\Exports\ZipExports\ZipImportRunner;
1818
use BookStack\Facades\Activity;
1919
use BookStack\Uploads\FileStorage;
20+
use Illuminate\Database\Eloquent\Builder;
2021
use Illuminate\Database\Eloquent\Collection;
2122
use Illuminate\Support\Facades\DB;
2223
use Symfony\Component\HttpFoundation\File\UploadedFile;
@@ -34,14 +35,19 @@ public function __construct(
3435
* @return Collection<Import>
3536
*/
3637
public function getVisibleImports(): Collection
38+
{
39+
return $this->queryVisible()->get();
40+
}
41+
42+
public function queryVisible(): Builder
3743
{
3844
$query = Import::query();
3945

4046
if (!userCan('settings-manage')) {
4147
$query->where('created_by', user()->id);
4248
}
4349

44-
return $query->get();
50+
return $query;
4551
}
4652

4753
public function findVisible(int $id): Import

app/Http/ApiController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
abstract class ApiController extends Controller
1010
{
11-
protected $rules = [];
11+
protected array $rules = [];
1212

1313
/**
1414
* Provide a paginated listing JSON response in a standard format

database/factories/Exports/ImportFactory.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public function definition(): array
2424
'path' => 'uploads/files/imports/' . Str::random(10) . '.zip',
2525
'name' => $this->faker->words(3, true),
2626
'type' => 'book',
27+
'size' => rand(1, 1001),
2728
'metadata' => '{"name": "My book"}',
2829
'created_at' => User::factory(),
2930
];

routes/api.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,11 @@
8888
Route::put('roles/{id}', [RoleApiController::class, 'update']);
8989
Route::delete('roles/{id}', [RoleApiController::class, 'delete']);
9090

91-
Route::get('import', [ExportControllers\ImportApiController::class, 'list']);
92-
Route::post('import', [ExportControllers\ImportApiController::class, 'upload']);
93-
Route::get('import/{id}', [ExportControllers\ImportApiController::class, 'read']);
94-
Route::post('import/{id}', [ExportControllers\ImportApiController::class, 'run']);
95-
Route::delete('import/{id}', [ExportControllers\ImportApiController::class, 'delete']);
91+
Route::get('imports', [ExportControllers\ImportApiController::class, 'list']);
92+
Route::post('imports', [ExportControllers\ImportApiController::class, 'upload']);
93+
Route::get('imports/{id}', [ExportControllers\ImportApiController::class, 'read']);
94+
Route::post('imports/{id}', [ExportControllers\ImportApiController::class, 'run']);
95+
Route::delete('imports/{id}', [ExportControllers\ImportApiController::class, 'delete']);
9696

9797
Route::get('recycle-bin', [EntityControllers\RecycleBinApiController::class, 'list']);
9898
Route::put('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'restore']);

tests/Api/ImportsApiTest.php

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<?php
2+
3+
namespace Api;
4+
5+
use BookStack\Entities\Models\Page;
6+
use BookStack\Exports\Import;
7+
use Tests\Api\TestsApi;
8+
use Tests\Exports\ZipTestHelper;
9+
use Tests\TestCase;
10+
11+
class ImportsApiTest extends TestCase
12+
{
13+
use TestsApi;
14+
15+
protected string $baseEndpoint = '/api/imports';
16+
17+
public function test_upload_and_run(): void
18+
{
19+
$book = $this->entities->book();
20+
$zip = ZipTestHelper::zipUploadFromData([
21+
'page' => [
22+
'name' => 'My API import page',
23+
'tags' => [
24+
[
25+
'name' => 'My api tag',
26+
'value' => 'api test value'
27+
]
28+
],
29+
],
30+
]);
31+
32+
$resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]);
33+
$resp->assertStatus(200);
34+
35+
$importId = $resp->json('id');
36+
$import = Import::query()->findOrFail($importId);
37+
$this->assertEquals('page', $import->type);
38+
39+
$resp = $this->post($this->baseEndpoint . "/{$import->id}", [
40+
'parent_type' => 'book',
41+
'parent_id' => $book->id,
42+
]);
43+
$resp->assertJson([
44+
'name' => 'My API import page',
45+
'book_id' => $book->id,
46+
]);
47+
48+
$page = Page::query()->where('name', '=', 'My API import page')->first();
49+
$this->assertEquals('My api tag', $page->tags()->first()->name);
50+
}
51+
52+
public function test_upload_validation_error(): void
53+
{
54+
$zip = ZipTestHelper::zipUploadFromData([
55+
'page' => [
56+
'tags' => [
57+
[
58+
'name' => 'My api tag',
59+
'value' => 'api test value'
60+
]
61+
],
62+
],
63+
]);
64+
65+
$resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]);
66+
$resp->assertStatus(422);
67+
$message = $resp->json('message');
68+
69+
$this->assertStringContainsString('ZIP upload failed with the following validation errors:', $message);
70+
$this->assertStringContainsString('[page.name] The name field is required.', $message);
71+
}
72+
73+
public function test_list(): void
74+
{
75+
$imports = Import::factory()->count(10)->create();
76+
77+
$resp = $this->actingAsApiAdmin()->get($this->baseEndpoint);
78+
$resp->assertJsonCount(10, 'data');
79+
$resp->assertJsonPath('total', 10);
80+
81+
$firstImport = $imports->first();
82+
$resp = $this->actingAsApiAdmin()->get($this->baseEndpoint . '?filter[id]=' . $firstImport->id);
83+
$resp->assertJsonCount(1, 'data');
84+
$resp->assertJsonPath('data.0.id', $firstImport->id);
85+
$resp->assertJsonPath('data.0.name', $firstImport->name);
86+
$resp->assertJsonPath('data.0.size', $firstImport->size);
87+
$resp->assertJsonPath('data.0.type', $firstImport->type);
88+
}
89+
90+
public function test_list_visibility_limited(): void
91+
{
92+
$user = $this->users->editor();
93+
$admin = $this->users->admin();
94+
$userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]);
95+
$adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]);
96+
$this->permissions->grantUserRolePermissions($user, ['content-import']);
97+
98+
$resp = $this->actingAsForApi($user)->get($this->baseEndpoint);
99+
$resp->assertJsonCount(1, 'data');
100+
$resp->assertJsonPath('data.0.name', 'MySuperUserImport');
101+
102+
$this->permissions->grantUserRolePermissions($user, ['settings-manage']);
103+
104+
$resp = $this->actingAsForApi($user)->get($this->baseEndpoint);
105+
$resp->assertJsonCount(2, 'data');
106+
$resp->assertJsonPath('data.1.name', 'MySuperAdminImport');
107+
}
108+
109+
public function test_read(): void
110+
{
111+
$zip = ZipTestHelper::zipUploadFromData([
112+
'book' => [
113+
'name' => 'My API import book',
114+
'pages' => [
115+
[
116+
'name' => 'My import page',
117+
'tags' => [
118+
[
119+
'name' => 'My api tag',
120+
'value' => 'api test value'
121+
]
122+
]
123+
]
124+
],
125+
],
126+
]);
127+
128+
$resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]);
129+
$resp->assertStatus(200);
130+
131+
$resp = $this->get($this->baseEndpoint . "/{$resp->json('id')}");
132+
$resp->assertStatus(200);
133+
134+
$resp->assertJsonPath('details.name', 'My API import book');
135+
$resp->assertJsonPath('details.pages.0.name', 'My import page');
136+
$resp->assertJsonPath('details.pages.0.tags.0.name', 'My api tag');
137+
$resp->assertJsonMissingPath('metadata');
138+
}
139+
140+
public function test_delete(): void
141+
{
142+
$import = Import::factory()->create();
143+
144+
$resp = $this->actingAsApiAdmin()->delete($this->baseEndpoint . "/{$import->id}");
145+
$resp->assertStatus(204);
146+
}
147+
148+
public function test_content_import_permissions_needed(): void
149+
{
150+
$user = $this->users->viewer();
151+
$this->permissions->grantUserRolePermissions($user, ['access-api']);
152+
$this->actingAsForApi($user);
153+
$requests = [
154+
['GET', $this->baseEndpoint],
155+
['POST', $this->baseEndpoint],
156+
['GET', $this->baseEndpoint . "/1"],
157+
['POST', $this->baseEndpoint . "/1"],
158+
['DELETE', $this->baseEndpoint . "/1"],
159+
];
160+
161+
foreach ($requests as $request) {
162+
[$method, $endpoint] = $request;
163+
$resp = $this->json($method, $endpoint);
164+
$resp->assertStatus(403);
165+
}
166+
167+
$this->permissions->grantUserRolePermissions($user, ['content-import']);
168+
169+
foreach ($requests as $request) {
170+
[$method, $endpoint] = $request;
171+
$resp = $this->call($method, $endpoint);
172+
$this->assertNotEquals(403, $resp->status(), "A {$method} request to {$endpoint} returned 403");
173+
}
174+
}
175+
}

0 commit comments

Comments
 (0)