3
0
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:
snipe
2026-07-26 00:00:06 +01:00
parent 03053d4d52
commit cb58d56663
4 changed files with 565 additions and 0 deletions

View 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
View 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),
];

View 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',
]);
}
}

View 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' => '',
];
}
}