mirror of
https://github.com/snipe/snipe-it.git
synced 2026-08-18 03:06:23 +00:00
More test stuff
This commit is contained in:
255
app/Importer/AssetHistoryImporter.php
Normal file
255
app/Importer/AssetHistoryImporter.php
Normal file
@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace App\Importer;
|
||||
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class AssetHistoryImporter extends Importer
|
||||
{
|
||||
protected $matchUsername = false;
|
||||
|
||||
protected $matchEmail = false;
|
||||
|
||||
protected $matchFirstnameLastname = false;
|
||||
|
||||
protected $matchFlastname = false;
|
||||
|
||||
protected $matchFirstname = false;
|
||||
|
||||
// Base defaultFieldMap doesn't know about the two date columns this
|
||||
// importer uses, and its `full_name => "full name"` mapping doesn't
|
||||
// match the historical CSV template's bare "Name" column. Merge these
|
||||
// in every time setFieldMappings runs (which ItemImportRequest does
|
||||
// even for callers that don't pass column-mappings, wiping constructor-
|
||||
// level overrides).
|
||||
private array $historyFieldMapExtras = [
|
||||
'checkout_date' => 'checkout date',
|
||||
'checkin_date' => 'checkin date',
|
||||
'full_name' => 'name',
|
||||
];
|
||||
|
||||
public function setFieldMappings($fields)
|
||||
{
|
||||
parent::setFieldMappings($fields);
|
||||
$this->fieldMap = array_merge($this->fieldMap, $this->historyFieldMapExtras);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setMatchUsername(bool $flag): self
|
||||
{
|
||||
$this->matchUsername = $flag;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setMatchEmail(bool $flag): self
|
||||
{
|
||||
$this->matchEmail = $flag;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setMatchFirstnameLastname(bool $flag): self
|
||||
{
|
||||
$this->matchFirstnameLastname = $flag;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setMatchFlastname(bool $flag): self
|
||||
{
|
||||
$this->matchFlastname = $flag;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setMatchFirstname(bool $flag): self
|
||||
{
|
||||
$this->matchFirstname = $flag;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function handle($row)
|
||||
{
|
||||
$asset_tag = $this->findCsvMatch($row, 'asset_tag');
|
||||
$name = $this->findCsvMatch($row, 'full_name');
|
||||
$checkout_date_raw = $this->findCsvMatch($row, 'checkout_date');
|
||||
$checkin_date_raw = $this->findCsvMatch($row, 'checkin_date');
|
||||
|
||||
// Was a checkin column present in the CSV at all? If not, the
|
||||
// original importer assumes every row represents an already-
|
||||
// checked-in asset (no assignment mutation, checkin actionlog
|
||||
// stamped at import time).
|
||||
$checkinHeaderPresent = array_key_exists($this->lookupCustomKey('checkin_date'), $row);
|
||||
|
||||
if (empty($asset_tag)) {
|
||||
$this->log('Row is missing an asset tag - skipping');
|
||||
$this->addRowError(
|
||||
trans('admin/hardware/message.import.history.missing_asset_tag_identity'),
|
||||
trans('general.import-history'),
|
||||
'asset_tag',
|
||||
trans('admin/hardware/message.import.history.missing_asset_tag_message'),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$asset = Asset::where('asset_tag', '=', $asset_tag)->first();
|
||||
|
||||
if (! $asset) {
|
||||
$this->log('Asset '.$asset_tag.' does not exist - skipping');
|
||||
$this->addRowError(
|
||||
trans('general.asset').' '.$asset_tag,
|
||||
trans('general.import-history'),
|
||||
'asset_tag',
|
||||
trans('admin/hardware/message.import.history.asset_not_found_message'),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$checkout_date = Carbon::parse($checkout_date_raw)->format('Y-m-d H:i:s');
|
||||
|
||||
$checkin_date = null;
|
||||
if ($checkinHeaderPresent) {
|
||||
if (! empty($checkin_date_raw)) {
|
||||
$checkin_date = Carbon::parse($checkin_date_raw)->format('Y-m-d H:i:s');
|
||||
}
|
||||
} else {
|
||||
// No checkin column in the header - assume already checked in
|
||||
// as of import time so we don't leave the asset in a state that
|
||||
// implies an open, indefinite checkout.
|
||||
$checkin_date = Carbon::parse(now())->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$user = $this->resolveTargetUser($name);
|
||||
|
||||
if (! $user) {
|
||||
$this->log('User "'.$name.'" does not exist so no checkin log was created for asset '.$asset_tag);
|
||||
$this->addRowError(
|
||||
trans('general.asset').' '.$asset_tag,
|
||||
trans('general.import-history'),
|
||||
'name',
|
||||
trans('admin/hardware/message.import.history.user_not_matched_message', ['name' => $name]),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Actionlog::firstOrCreate([
|
||||
'item_id' => $asset->id,
|
||||
'item_type' => Asset::class,
|
||||
'created_by' => $this->created_by,
|
||||
'note' => 'Checkout imported by '.(auth()->user()?->display_name ?? 'CSV importer').' from history importer',
|
||||
'target_id' => $user->id,
|
||||
'target_type' => User::class,
|
||||
'created_at' => $checkout_date,
|
||||
'action_type' => 'checkout',
|
||||
]);
|
||||
|
||||
// If the CSV explicitly told us about a checkin column and this row's
|
||||
// checkin is empty or in the future, the asset is still assigned to
|
||||
// the target user. Otherwise leave the current assignment alone -
|
||||
// the asset is (by the source-of-truth CSV) already checked in.
|
||||
if ($checkinHeaderPresent) {
|
||||
if (empty($checkin_date) || strtotime($checkin_date) > strtotime(Carbon::now())) {
|
||||
$asset->assigned_to = $user->id;
|
||||
$asset->assigned_type = User::class;
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($checkin_date)) {
|
||||
Actionlog::firstOrCreate([
|
||||
'item_id' => $asset->id,
|
||||
'item_type' => Asset::class,
|
||||
'created_by' => $this->created_by,
|
||||
'note' => 'Checkin imported by '.(auth()->user()?->display_name ?? 'CSV importer').' from history importer',
|
||||
'target_id' => null,
|
||||
'created_at' => $checkin_date,
|
||||
'action_type' => 'checkin',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($asset->save()) {
|
||||
$this->log('Asset history imported for '.$asset_tag.' -> '.$user->username.' at '.$checkout_date);
|
||||
} else {
|
||||
$this->logError($asset, 'asset_tag');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a row-level error onto the same bag the standard CRUD
|
||||
* importers use, so the wizard's post-run error table surfaces the
|
||||
* skip reason to the user instead of hiding it in laravel.log.
|
||||
*
|
||||
* ItemImportRequest::errorCallback signature is
|
||||
* ($item, $field, $errorString), reads $item->name for the row
|
||||
* identity, and stores as errors[$item->name][$field] = $errorString.
|
||||
* The wizard's error table then iterates $errorString as an inner
|
||||
* [innerField => [message strings]] map, so we hand it that exact
|
||||
* shape here. Wraps with a stdClass because we don't have a real
|
||||
* model at row-error time.
|
||||
*
|
||||
* Bypasses the base addErrorToBag() helper - that one wraps the
|
||||
* message in an extra [$field => [...]] layer which produces
|
||||
* quadruple-nested arrays and blows up the blade's implode(). Direct
|
||||
* callback invocation matches what logError() effectively produces
|
||||
* from validating models.
|
||||
*/
|
||||
private function addRowError(string $identity, string $tableLabel, string $field, string $message): void
|
||||
{
|
||||
if (! $this->errorCallback) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fake = new \stdClass;
|
||||
$fake->name = $identity;
|
||||
call_user_func(
|
||||
$this->errorCallback,
|
||||
$fake,
|
||||
$tableLabel,
|
||||
[$field => [$message]],
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveTargetUser(?string $name): ?User
|
||||
{
|
||||
if (empty($name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$base = User::generateFormattedNameFromFullName(Setting::getSettings()->username_format, $name);
|
||||
$query = User::where('username', '=', $base['username']);
|
||||
|
||||
if ($this->matchFirstnameLastname) {
|
||||
$firstDotLast = User::generateFormattedNameFromFullName('firstname.lastname', $name);
|
||||
$query->orWhere('username', '=', $firstDotLast['username']);
|
||||
}
|
||||
|
||||
if ($this->matchFlastname) {
|
||||
$flastname = User::generateFormattedNameFromFullName('filastname', $name);
|
||||
$query->orWhere('username', '=', $flastname['username']);
|
||||
}
|
||||
|
||||
if ($this->matchFirstname) {
|
||||
$firstname = User::generateFormattedNameFromFullName('firstname', $name);
|
||||
$query->orWhere('username', '=', $firstname['username']);
|
||||
}
|
||||
|
||||
if ($this->matchEmail) {
|
||||
$query->orWhere('email', '=', User::generateEmailFromFullName($name));
|
||||
}
|
||||
|
||||
if ($this->matchUsername) {
|
||||
$query->orWhere('username', '=', $name);
|
||||
}
|
||||
|
||||
return $query->first();
|
||||
}
|
||||
}
|
||||
29
config/importer.php
Normal file
29
config/importer.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Slice size for CSV imports
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The Livewire importer breaks a large CSV into fixed-size chunks and
|
||||
| fires one HTTP request per chunk so no single request stays open long
|
||||
| enough to bump PHP's max_execution_time or an upstream proxy timeout.
|
||||
| Each chunk is processed inside its own DB::transaction, so a failure
|
||||
| in chunk K rolls back only chunk K - earlier chunks stay committed.
|
||||
|
|
||||
| 500 rows is a compromise: small enough to comfortably fit inside a
|
||||
| 60-second request budget on modest hardware even for asset imports
|
||||
| that touch categories / manufacturers / models / statuslabels /
|
||||
| actionlogs per row, large enough that the round-trip overhead
|
||||
| between slices doesn't dominate for imports of a few thousand rows.
|
||||
|
|
||||
| Lower this if your install hits per-request timeouts on complex
|
||||
| imports; raise it if a big import feels chatty because of network
|
||||
| round trips.
|
||||
*/
|
||||
|
||||
'slice_size' => env('IMPORT_SLICE_SIZE', 500),
|
||||
|
||||
];
|
||||
238
tests/Feature/Assets/Ui/ImportAssetHistoryTest.php
Normal file
238
tests/Feature/Assets/Ui/ImportAssetHistoryTest.php
Normal file
@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Assets\Ui;
|
||||
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Import;
|
||||
use App\Models\User;
|
||||
use Tests\Support\Importing\AssetHistoryImportFileBuilder;
|
||||
use Tests\Support\Importing\CleansUpImportFiles;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ImportAssetHistoryTest extends TestCase
|
||||
{
|
||||
use CleansUpImportFiles;
|
||||
|
||||
public function test_legacy_get_history_route_redirects_to_importer(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create())
|
||||
->get('/hardware/history')
|
||||
->assertRedirect(route('imports.index'));
|
||||
}
|
||||
|
||||
public function test_legacy_post_history_endpoint_is_gone(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->admin()->create())
|
||||
->post('/hardware/history')
|
||||
->assertStatus(405);
|
||||
}
|
||||
|
||||
public function test_process_endpoint_blocked_in_demo_mode(): void
|
||||
{
|
||||
// Uploads were already blocked at Api\ImportController::store,
|
||||
// but the process endpoint would still let a demo user mutate the
|
||||
// DB via any seeded / leftover Import row. The lock_passwords
|
||||
// gate here closes that loophole.
|
||||
config(['app.lock_passwords' => true]);
|
||||
|
||||
$actor = User::factory()->canImport()->superuser()->create();
|
||||
$import = Import::factory()->assetHistory()->create(['created_by' => $actor->id]);
|
||||
|
||||
$this->actingAsForApi($actor);
|
||||
$this->postJson(
|
||||
route('api.imports.importFile', ['import' => $import->id]),
|
||||
['import-type' => 'assetHistory', 'import' => $import->id],
|
||||
)->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_asset_history_import_requires_import_permission(): void
|
||||
{
|
||||
$actor = User::factory()->create();
|
||||
$import = Import::factory()->assetHistory()->create(['created_by' => $actor->id]);
|
||||
|
||||
$this->actingAsForApi($actor);
|
||||
$this->postJson(
|
||||
route('api.imports.importFile', ['import' => $import->id]),
|
||||
['import-type' => 'assetHistory', 'import' => $import->id],
|
||||
)->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_asset_history_import_creates_actionlogs_and_assigns_user(): void
|
||||
{
|
||||
$actor = User::factory()->canImport()->create();
|
||||
$target = User::factory()->create(['username' => 'target.user']);
|
||||
$asset = Asset::factory()->create([
|
||||
'asset_tag' => 'AHIST-1',
|
||||
'assigned_to' => null,
|
||||
'assigned_type' => null,
|
||||
]);
|
||||
|
||||
$checkoutDate = now()->subDay()->format('Y-m-d H:i:s');
|
||||
$checkinDate = now()->addDays(30)->format('Y-m-d H:i:s');
|
||||
|
||||
$file = AssetHistoryImportFileBuilder::new([
|
||||
'assetTag' => $asset->asset_tag,
|
||||
'name' => $target->username,
|
||||
'email' => '',
|
||||
'checkoutDate' => $checkoutDate,
|
||||
'checkinDate' => $checkinDate,
|
||||
]);
|
||||
|
||||
$import = Import::factory()->assetHistory()->create([
|
||||
'created_by' => $actor->id,
|
||||
'file_path' => $file->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->actingAsForApi($actor);
|
||||
$this->postJson(
|
||||
route('api.imports.importFile', ['import' => $import->id]),
|
||||
[
|
||||
'import-type' => 'assetHistory',
|
||||
'import' => $import->id,
|
||||
'match_username' => true,
|
||||
],
|
||||
)->assertOk();
|
||||
|
||||
$asset->refresh();
|
||||
$this->assertEquals($target->id, $asset->assigned_to);
|
||||
$this->assertEquals(User::class, $asset->assigned_type);
|
||||
|
||||
$this->assertDatabaseHas('action_logs', [
|
||||
'item_id' => $asset->id,
|
||||
'item_type' => Asset::class,
|
||||
'target_id' => $target->id,
|
||||
'target_type' => User::class,
|
||||
'action_type' => 'checkout',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('action_logs', [
|
||||
'item_id' => $asset->id,
|
||||
'item_type' => Asset::class,
|
||||
'target_id' => null,
|
||||
'action_type' => 'checkin',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_asset_history_import_does_not_reassign_when_checkin_is_past(): void
|
||||
{
|
||||
$actor = User::factory()->canImport()->create();
|
||||
$target = User::factory()->create(['username' => 'past.user']);
|
||||
$asset = Asset::factory()->create([
|
||||
'asset_tag' => 'AHIST-2',
|
||||
'assigned_to' => null,
|
||||
'assigned_type' => null,
|
||||
]);
|
||||
|
||||
$file = AssetHistoryImportFileBuilder::new([
|
||||
'assetTag' => $asset->asset_tag,
|
||||
'name' => $target->username,
|
||||
'email' => '',
|
||||
'checkoutDate' => now()->subDays(30)->format('Y-m-d H:i:s'),
|
||||
'checkinDate' => now()->subDays(15)->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$import = Import::factory()->assetHistory()->create([
|
||||
'created_by' => $actor->id,
|
||||
'file_path' => $file->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->actingAsForApi($actor);
|
||||
$this->postJson(
|
||||
route('api.imports.importFile', ['import' => $import->id]),
|
||||
[
|
||||
'import-type' => 'assetHistory',
|
||||
'import' => $import->id,
|
||||
'match_username' => true,
|
||||
],
|
||||
)->assertOk();
|
||||
|
||||
$asset->refresh();
|
||||
$this->assertNull($asset->assigned_to);
|
||||
$this->assertNull($asset->assigned_type);
|
||||
|
||||
// Historical checkout + checkin actionlogs both got written even
|
||||
// though the asset ends up in a checked-in state.
|
||||
$this->assertDatabaseHas('action_logs', [
|
||||
'item_id' => $asset->id,
|
||||
'action_type' => 'checkout',
|
||||
]);
|
||||
$this->assertDatabaseHas('action_logs', [
|
||||
'item_id' => $asset->id,
|
||||
'action_type' => 'checkin',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_asset_history_import_skips_unknown_asset_tag(): void
|
||||
{
|
||||
$actor = User::factory()->canImport()->create();
|
||||
User::factory()->create(['username' => 'someone']);
|
||||
|
||||
$file = AssetHistoryImportFileBuilder::new([
|
||||
'assetTag' => 'DOES-NOT-EXIST',
|
||||
'name' => 'someone',
|
||||
'email' => '',
|
||||
'checkoutDate' => now()->format('Y-m-d H:i:s'),
|
||||
'checkinDate' => '',
|
||||
]);
|
||||
|
||||
$import = Import::factory()->assetHistory()->create([
|
||||
'created_by' => $actor->id,
|
||||
'file_path' => $file->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->actingAsForApi($actor);
|
||||
// Skipped rows now surface as import-errors so the wizard can
|
||||
// display the reason (Asset does not exist) instead of hiding it
|
||||
// in laravel.log. The 500 status is the shared API contract for
|
||||
// an errors-non-empty return.
|
||||
$response = $this->postJson(
|
||||
route('api.imports.importFile', ['import' => $import->id]),
|
||||
['import-type' => 'assetHistory', 'import' => $import->id, 'match_username' => true],
|
||||
);
|
||||
$response->assertStatus(500);
|
||||
$this->assertEquals('import-errors', $response->json('status'));
|
||||
$this->assertArrayHasKey('Asset DOES-NOT-EXIST', $response->json('messages'));
|
||||
|
||||
$this->assertSame(0, Actionlog::where('note', 'like', '%history importer%')->count());
|
||||
}
|
||||
|
||||
public function test_asset_history_import_skips_row_when_user_not_matched(): void
|
||||
{
|
||||
$actor = User::factory()->canImport()->create();
|
||||
$asset = Asset::factory()->create([
|
||||
'asset_tag' => 'AHIST-3',
|
||||
'assigned_to' => null,
|
||||
'assigned_type' => null,
|
||||
]);
|
||||
|
||||
$file = AssetHistoryImportFileBuilder::new([
|
||||
'assetTag' => $asset->asset_tag,
|
||||
'name' => 'no.such.user',
|
||||
'email' => '',
|
||||
'checkoutDate' => now()->format('Y-m-d H:i:s'),
|
||||
'checkinDate' => '',
|
||||
]);
|
||||
|
||||
$import = Import::factory()->assetHistory()->create([
|
||||
'created_by' => $actor->id,
|
||||
'file_path' => $file->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->actingAsForApi($actor);
|
||||
$response = $this->postJson(
|
||||
route('api.imports.importFile', ['import' => $import->id]),
|
||||
['import-type' => 'assetHistory', 'import' => $import->id, 'match_username' => true],
|
||||
);
|
||||
$response->assertStatus(500);
|
||||
$this->assertEquals('import-errors', $response->json('status'));
|
||||
$this->assertArrayHasKey('Asset '.$asset->asset_tag, $response->json('messages'));
|
||||
|
||||
$asset->refresh();
|
||||
$this->assertNull($asset->assigned_to);
|
||||
$this->assertDatabaseMissing('action_logs', [
|
||||
'item_id' => $asset->id,
|
||||
'action_type' => 'checkout',
|
||||
]);
|
||||
}
|
||||
}
|
||||
43
tests/Support/Importing/AssetHistoryImportFileBuilder.php
Normal file
43
tests/Support/Importing/AssetHistoryImportFileBuilder.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support\Importing;
|
||||
|
||||
/**
|
||||
* Build an asset-history import file at runtime for testing.
|
||||
*
|
||||
* @template Row of array{
|
||||
* assetTag?: string,
|
||||
* name?: string,
|
||||
* email?: string,
|
||||
* checkoutDate?: string,
|
||||
* checkinDate?: string,
|
||||
* }
|
||||
*
|
||||
* @extends FileBuilder<Row>
|
||||
*/
|
||||
class AssetHistoryImportFileBuilder extends FileBuilder
|
||||
{
|
||||
protected function getDictionary(): array
|
||||
{
|
||||
return [
|
||||
'assetTag' => 'Asset Tag',
|
||||
'name' => 'Name',
|
||||
'email' => 'Email',
|
||||
'checkoutDate' => 'Checkout Date',
|
||||
'checkinDate' => 'Checkin Date',
|
||||
];
|
||||
}
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'assetTag' => 'AH-'.fake()->unique()->randomNumber(6),
|
||||
'name' => fake()->userName,
|
||||
'email' => fake()->safeEmail,
|
||||
'checkoutDate' => fake()->date,
|
||||
'checkinDate' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user