From 96bf7d0c2b2cea6ec2be7c97b67ae67b2e936d1e Mon Sep 17 00:00:00 2001 From: snipe Date: Wed, 13 May 2026 12:06:13 +0100 Subject: [PATCH] Fixed FD-52058 - Importer using wrong created_at date --- app/Importer/AccessoryImporter.php | 3 +- app/Importer/AssetImporter.php | 36 +++- app/Importer/ComponentImporter.php | 3 +- app/Importer/ConsumableImporter.php | 3 +- app/Importer/LicenseImporter.php | 8 +- sample_csvs/assets-sample.csv | 2 +- .../Importing/Api/ImportAccessoriesTest.php | 43 ++++ .../Importing/Api/ImportAssetModelsTest.php | 43 ++++ .../Importing/Api/ImportAssetsTest.php | 52 +++++ .../Importing/Api/ImportComponentsTest.php | 46 +++++ .../Importing/Api/ImportConsumablesTest.php | 43 ++++ .../Importing/Api/ImportLicenseTest.php | 46 +++++ .../Importing/Api/ImportLocationsTest.php | 43 ++++ .../Feature/Importing/Api/ImportUsersTest.php | 43 ++++ .../Importing/AssetImportCreatedAtTest.php | 193 ++++++++++++++++++ 15 files changed, 593 insertions(+), 14 deletions(-) create mode 100644 tests/Feature/Importing/AssetImportCreatedAtTest.php diff --git a/app/Importer/AccessoryImporter.php b/app/Importer/AccessoryImporter.php index 12714fb785..c5026751ce 100644 --- a/app/Importer/AccessoryImporter.php +++ b/app/Importer/AccessoryImporter.php @@ -37,7 +37,8 @@ class AccessoryImporter extends ItemImporter $this->log('Updating Accessory'); $this->item['model_number'] = trim($this->findCsvMatch($row, 'model_number')); $accessory->update($this->sanitizeItemForUpdating($accessory)); - $accessory->save(); + // update() already saves the model, no need to call save() again while Model::unguard() is active + $accessory->setImported(true); return; } diff --git a/app/Importer/AssetImporter.php b/app/Importer/AssetImporter.php index 2710622244..d100f586ea 100644 --- a/app/Importer/AssetImporter.php +++ b/app/Importer/AssetImporter.php @@ -176,35 +176,55 @@ class AssetImporter extends ItemImporter if ($editingAsset) { $asset->update($item); + $asset->setImported(true); } else { $asset->fill($item); + $asset->setImported(true); } // If we're updating, we don't want to overwrite old fields. + // Apply custom fields to asset attributes if they exist + $customFieldsToSave = []; if (array_key_exists('custom_fields', $this->item)) { foreach ($this->item['custom_fields'] as $custom_field => $val) { $asset->{$custom_field} = $val; + $customFieldsToSave[$custom_field] = $val; } } - // This sets an attribute on the Loggable trait for the action log - $asset->setImported(true); + // For existing assets that have custom fields, update them. + // This avoids the issue of calling save() twice with Model::unguard() active. + if ($editingAsset && !empty($customFieldsToSave)) { + $asset->update($customFieldsToSave); + $success = true; + } elseif (!$editingAsset) { + // For new assets, save with all changes (custom fields included via direct attribute assignment above) + $success = $asset->save(); + } else { + // For existing assets without custom fields, update() already saved everything + $success = true; + } - if ($asset->save()) { + if ($success) { - $this->log('Asset '.$this->item['name'].' with serial number '.$this->item['serial'].' was created'); + $this->log('Asset '.$this->item['name'].' with serial number '.$this->item['serial'].' created or updated'); // If we have a target to checkout to, lets do so. // -- created_by is a property of the abstract class Importer, which this class inherits from and it's set by // -- the class that needs to use it (command importer or GUI importer inside the project). if (isset($target) && ($target !== false)) { - if (! is_null($asset->assigned_to)) { - if ($asset->assigned_to != $target->id) { + $asset = $asset->fresh(); + $targetType = get_class($target); + $alreadyCheckedOutToTarget = ($asset->assigned_to == $target->id) && ($asset->assigned_type === $targetType); + + // Skip duplicate checkout noise when update mode keeps the same assignment target. + if (! $alreadyCheckedOutToTarget) { + if (! is_null($asset->assigned_to)) { event(new CheckoutableCheckedIn($asset, $asset->assigned, auth()->user(), 'Checkin from CSV Importer', $checkin_date)); } - } - $asset->fresh()->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name); + $asset->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name); + } } return; diff --git a/app/Importer/ComponentImporter.php b/app/Importer/ComponentImporter.php index 312da48eed..5066c8eb2b 100644 --- a/app/Importer/ComponentImporter.php +++ b/app/Importer/ComponentImporter.php @@ -42,7 +42,8 @@ class ComponentImporter extends ItemImporter } $this->log('Updating Component'); $component->update($this->sanitizeItemForUpdating($component)); - $component->save(); + // update() already saves the model, no need to call save() again while Model::unguard() is active + $component->setImported(true); return; } diff --git a/app/Importer/ConsumableImporter.php b/app/Importer/ConsumableImporter.php index b1eb7eb5c4..26790ee386 100644 --- a/app/Importer/ConsumableImporter.php +++ b/app/Importer/ConsumableImporter.php @@ -38,7 +38,8 @@ class ConsumableImporter extends ItemImporter } $this->log('Updating Consumable'); $consumable->update($this->sanitizeItemForUpdating($consumable)); - $consumable->save(); + // update() already saves the model, no need to call save() again while Model::unguard() is active + $consumable->setImported(true); return; } diff --git a/app/Importer/LicenseImporter.php b/app/Importer/LicenseImporter.php index 108981fe10..101adc3b41 100644 --- a/app/Importer/LicenseImporter.php +++ b/app/Importer/LicenseImporter.php @@ -88,8 +88,12 @@ class LicenseImporter extends ItemImporter // This sets an attribute on the Loggable trait for the action log $license->setImported(true); - if ($license->save()) { - $this->log('License '.$this->item['name'].' with serial number '.$this->item['serial'].' was created'); + + // For new licenses we need to save, for existing ones update() already saved + $licenseWasSaved = $editingLicense || $license->save(); + + if ($licenseWasSaved) { + $this->log('License '.$this->item['name'].' with serial number '.$this->item['serial'].' was created or updated'); // Lets try to checkout seats if the fields exist and we have seats. if ($license->seats > 0) { diff --git a/sample_csvs/assets-sample.csv b/sample_csvs/assets-sample.csv index 6594bc690e..ff22d2962d 100644 --- a/sample_csvs/assets-sample.csv +++ b/sample_csvs/assets-sample.csv @@ -1,5 +1,5 @@ Company,Name,Asset Tag,Category,Supplier,Manufacturer,Location,Order Number,Model,Model Notes,Model Number,Asset Notes,Purchase Date,Purchase Cost,Checkout Type,Checked Out To: Username,Checked Out To: First Name,Checked Out To: Last Name,Checked Out To: Email,Checked Out To: Location,Asset EOL Date -Abshire and Sons,Backhoe,ICC-2065556,Ornamental Railings,"Kunde, Doyle and Kozey",Berge Inc,"Wilkinson, Waters and Kerluke",3271901481,"Macbook Pro 13""",,1786VM80X07,at nulla suspendisse potenti cras in purus eu magna vulputate luctus cum sociis natoque penatibus et magnis dis,1/23/23,2266.13,,,,,,,10/27/28 +Abshire and Sons and Sons and Sons and Sons!,Backhoe,ICC-2065556,Ornamental Railings,"Kunde, Doyle and Kozey",Berge Inc,"Wilkinson, Waters and Kerluke",3271901481,"Macbook Pro 13""",,1786VM80X07,at nulla suspendisse potenti cras in purus eu magna vulputate luctus cum sociis natoque penatibus et magnis dis,1/23/23,2266.13,,,,,,,10/27/28 "Quitzon, Oberbrunner and Dibbert",Dragline,WBH-2841795,Structural and Misc Steel (Fabrication),Krajcik LLC,"Botsford, Boyle and Herzog",Lindgren-Marquardt,5504512275,"Macbook Pro 13""",ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae mauris viverra diam vitae quam suspendisse potenti nullam,9351IS25A51,aliquam convallis nunc proin at turpis a pede posuere nonummy integer,11/14/22,1292.94,User,gmccrackem2a,Gage,McCrackem,gmccrackem2a@bing.com,,10/27/28 Boyer and Sons,Excavator,NNH-3656031,Soft Flooring and Base,"Heaney, Altenwerth and Emmerich",Pollich LLC,Pacocha-Kiehn,4861125177,"Macbook Pro 13""",,9929FR08W85,,3/1/23,2300.71,Location,,,,,Pacocha-Kiehn,10/27/28 Hayes-Rippin,Trencher,BOL-0305383,Prefabricated Aluminum Metal Canopies,"Botsford, Boyle and Herzog",Walker-Towne,Fritsch-Abernathy,2416994639,"Macbook Pro 13""",neque vestibulum eget vulputate ut ultrices vel augue vestibulum ante ipsum primis in faucibus orci luctus,9139KQ78G81,,10/26/22,1777.56,User,ksennett6,Katerina,Sennett,ksennett6@ibm.com,,10/27/28 diff --git a/tests/Feature/Importing/Api/ImportAccessoriesTest.php b/tests/Feature/Importing/Api/ImportAccessoriesTest.php index 5421aae24a..de668c742e 100644 --- a/tests/Feature/Importing/Api/ImportAccessoriesTest.php +++ b/tests/Feature/Importing/Api/ImportAccessoriesTest.php @@ -305,6 +305,49 @@ class ImportAccessoriesTest extends ImportDataTestCase implements TestsPermissio ); } + #[Test] + public function update_mode_logs_accessory_update_in_actionlog(): void + { + $this->actingAsForApi(User::factory()->superuser()->create()); + + $initialFile = ImportFileBuilder::new(); + $initialRow = $initialFile->firstRow(); + + $initialImport = Import::factory()->accessory()->create([ + 'file_path' => $initialFile->saveToImportsDirectory(), + ]); + + $this->importFileResponse(['import' => $initialImport->id])->assertOk(); + + $accessory = Accessory::query()->where('name', $initialRow['itemName'])->sole(); + + $updatedRow = array_merge($initialRow, [ + 'orderNumber' => (string) $initialRow['orderNumber'].'-UPD', + ]); + + $updateFile = new ImportFileBuilder([$updatedRow]); + $updateImport = Import::factory()->accessory()->create([ + 'file_path' => $updateFile->saveToImportsDirectory(), + ]); + + $this->importFileResponse([ + 'import' => $updateImport->id, + 'import-update' => true, + ])->assertOk(); + + $accessory->refresh(); + $this->assertEquals($updatedRow['orderNumber'], $accessory->order_number); + + $updateLog = Actionlog::query() + ->where('item_type', Accessory::class) + ->where('item_id', $accessory->id) + ->where('action_type', 'update') + ->latest('id') + ->first(); + + $this->assertNotNull($updateLog, 'Expected an update action log entry after accessory importer update mode.'); + } + #[Test] public function when_import_file_contains_empty_values(): void { diff --git a/tests/Feature/Importing/Api/ImportAssetModelsTest.php b/tests/Feature/Importing/Api/ImportAssetModelsTest.php index 4298a14cd5..a93de447a2 100644 --- a/tests/Feature/Importing/Api/ImportAssetModelsTest.php +++ b/tests/Feature/Importing/Api/ImportAssetModelsTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Importing\Api; +use App\Models\Actionlog as ActionLog; use App\Models\AssetModel; use App\Models\Category; use App\Models\Import; @@ -131,4 +132,46 @@ class ImportAssetModelsTest extends ImportDataTestCase implements TestsPermissio $this->assertEquals($row['name'], $updatedAssetmodel->name); } + + #[Test] + public function update_mode_logs_asset_model_update_in_actionlog(): void + { + $this->actingAsForApi(User::factory()->superuser()->create()); + + $initialFile = ImportFileBuilder::new(); + $initialRow = $initialFile->firstRow(); + $initialImport = Import::factory()->assetmodel()->create([ + 'file_path' => $initialFile->saveToImportsDirectory(), + ]); + + $this->importFileResponse(['import' => $initialImport->id])->assertOk(); + + $assetModel = AssetModel::query()->where('name', $initialRow['name'])->sole(); + + $updatedRow = array_merge($initialRow, [ + 'model_number' => Str::random(), + ]); + + $updateFile = new ImportFileBuilder([$updatedRow]); + $updateImport = Import::factory()->assetmodel()->create([ + 'file_path' => $updateFile->saveToImportsDirectory(), + ]); + + $this->importFileResponse([ + 'import' => $updateImport->id, + 'import-update' => true, + ])->assertOk(); + + $assetModel->refresh(); + $this->assertEquals($updatedRow['model_number'], $assetModel->model_number); + + $updateLog = ActionLog::query() + ->where('item_type', AssetModel::class) + ->where('item_id', $assetModel->id) + ->where('action_type', 'update') + ->latest('id') + ->first(); + + $this->assertNotNull($updateLog, 'Expected an update action log entry after asset model importer update mode.'); + } } diff --git a/tests/Feature/Importing/Api/ImportAssetsTest.php b/tests/Feature/Importing/Api/ImportAssetsTest.php index 2100e793f1..3f88f33e3c 100644 --- a/tests/Feature/Importing/Api/ImportAssetsTest.php +++ b/tests/Feature/Importing/Api/ImportAssetsTest.php @@ -427,6 +427,58 @@ class ImportAssetsTest extends ImportDataTestCase implements TestsPermissionsReq ); } + #[Test] + public function update_mode_logs_asset_update_in_actionlog(): void + { + $this->actingAsForApi(User::factory()->superuser()->create()); + + $initialFile = ImportFileBuilder::new(); + $initialRow = $initialFile->firstRow(); + + $initialImport = Import::factory()->asset()->create([ + 'file_path' => $initialFile->saveToImportsDirectory(), + ]); + + $this->importFileResponse(['import' => $initialImport->id])->assertOk(); + + $asset = Asset::query()->where('asset_tag', $initialRow['tag'])->sole(); + + $updatedRow = array_merge($initialRow, [ + 'itemName' => $initialRow['itemName'].' Updated', + ]); + + $updateFile = new ImportFileBuilder([$updatedRow]); + $updateImport = Import::factory()->asset()->create([ + 'file_path' => $updateFile->saveToImportsDirectory(), + ]); + + $this->importFileResponse([ + 'import' => $updateImport->id, + 'import-update' => true, + ])->assertOk(); + + $asset->refresh(); + $this->assertEquals($updatedRow['itemName'], $asset->name); + + $updateLog = ActionLog::query() + ->where('item_type', Asset::class) + ->where('item_id', $asset->id) + ->where('action_type', 'update') + ->latest('id') + ->first(); + + $this->assertNotNull($updateLog, 'Expected an update action log entry after importer update mode.'); + $this->assertStringContainsString('name', (string) $updateLog->log_meta); + + $checkoutLogsCount = ActionLog::query() + ->where('item_type', Asset::class) + ->where('item_id', $asset->id) + ->where('action_type', 'checkout') + ->count(); + + $this->assertSame(1, $checkoutLogsCount, 'Re-import update should not create a duplicate checkout log for the same assignment.'); + } + #[Test] public function custom_column_mapping(): void { diff --git a/tests/Feature/Importing/Api/ImportComponentsTest.php b/tests/Feature/Importing/Api/ImportComponentsTest.php index bd9cf1071d..9364ab3ca8 100644 --- a/tests/Feature/Importing/Api/ImportComponentsTest.php +++ b/tests/Feature/Importing/Api/ImportComponentsTest.php @@ -249,6 +249,52 @@ class ImportComponentsTest extends ImportDataTestCase implements TestsPermission $this->assertEquals($component->notes, $updatedComponent->notes); } + #[Test] + public function update_mode_logs_component_update_in_actionlog(): void + { + $this->actingAsForApi(User::factory()->superuser()->create()); + + $initialFile = ImportFileBuilder::new(); + $initialRow = $initialFile->firstRow(); + + $initialImport = Import::factory()->component()->create([ + 'file_path' => $initialFile->saveToImportsDirectory(), + ]); + + $this->importFileResponse(['import' => $initialImport->id])->assertOk(); + + $component = Component::query() + ->where('name', $initialRow['itemName']) + ->where('serial', $initialRow['serialNumber']) + ->sole(); + + $updatedRow = array_merge($initialRow, [ + 'orderNumber' => (string) $initialRow['orderNumber'].'-UPD', + ]); + + $updateFile = new ImportFileBuilder([$updatedRow]); + $updateImport = Import::factory()->component()->create([ + 'file_path' => $updateFile->saveToImportsDirectory(), + ]); + + $this->importFileResponse([ + 'import' => $updateImport->id, + 'import-update' => true, + ])->assertOk(); + + $component->refresh(); + $this->assertEquals($updatedRow['orderNumber'], $component->order_number); + + $updateLog = ActionLog::query() + ->where('item_type', Component::class) + ->where('item_id', $component->id) + ->where('action_type', 'update') + ->latest('id') + ->first(); + + $this->assertNotNull($updateLog, 'Expected an update action log entry after component importer update mode.'); + } + #[Test] public function custom_column_mapping(): void { diff --git a/tests/Feature/Importing/Api/ImportConsumablesTest.php b/tests/Feature/Importing/Api/ImportConsumablesTest.php index c7b86334b2..fd211913ea 100644 --- a/tests/Feature/Importing/Api/ImportConsumablesTest.php +++ b/tests/Feature/Importing/Api/ImportConsumablesTest.php @@ -243,6 +243,49 @@ class ImportConsumablesTest extends ImportDataTestCase implements TestsPermissio $this->assertEquals($consumable->item_number, $updatedConsumable->item_number); } + #[Test] + public function update_mode_logs_consumable_update_in_actionlog(): void + { + $this->actingAsForApi(User::factory()->superuser()->create()); + + $initialFile = ImportFileBuilder::new(); + $initialRow = $initialFile->firstRow(); + + $initialImport = Import::factory()->consumable()->create([ + 'file_path' => $initialFile->saveToImportsDirectory(), + ]); + + $this->importFileResponse(['import' => $initialImport->id])->assertOk(); + + $consumable = Consumable::query()->where('name', $initialRow['itemName'])->sole(); + + $updatedRow = array_merge($initialRow, [ + 'orderNumber' => (string) $initialRow['orderNumber'].'-UPD', + ]); + + $updateFile = new ImportFileBuilder([$updatedRow]); + $updateImport = Import::factory()->consumable()->create([ + 'file_path' => $updateFile->saveToImportsDirectory(), + ]); + + $this->importFileResponse([ + 'import' => $updateImport->id, + 'import-update' => true, + ])->assertOk(); + + $consumable->refresh(); + $this->assertEquals($updatedRow['orderNumber'], $consumable->order_number); + + $updateLog = ActivityLog::query() + ->where('item_type', Consumable::class) + ->where('item_id', $consumable->id) + ->where('action_type', 'update') + ->latest('id') + ->first(); + + $this->assertNotNull($updateLog, 'Expected an update action log entry after consumable importer update mode.'); + } + #[Test] public function custom_column_mapping(): void { diff --git a/tests/Feature/Importing/Api/ImportLicenseTest.php b/tests/Feature/Importing/Api/ImportLicenseTest.php index 090daa26e7..8699689660 100644 --- a/tests/Feature/Importing/Api/ImportLicenseTest.php +++ b/tests/Feature/Importing/Api/ImportLicenseTest.php @@ -277,6 +277,52 @@ class ImportLicenseTest extends ImportDataTestCase implements TestsPermissionsRe $this->assertEquals($license->min_amt, $updatedLicense->min_amt); } + #[Test] + public function update_mode_logs_license_update_in_actionlog(): void + { + $this->actingAsForApi(User::factory()->superuser()->create()); + + $initialFile = ImportFileBuilder::new(); + $initialRow = $initialFile->firstRow(); + + $initialImport = Import::factory()->license()->create([ + 'file_path' => $initialFile->saveToImportsDirectory(), + ]); + + $this->importFileResponse(['import' => $initialImport->id])->assertOk(); + + $license = License::query() + ->where('name', $initialRow['licenseName']) + ->where('serial', $initialRow['serialNumber']) + ->sole(); + + $updatedRow = array_merge($initialRow, [ + 'orderNumber' => (string) $initialRow['orderNumber'].'-UPD', + ]); + + $updateFile = new ImportFileBuilder([$updatedRow]); + $updateImport = Import::factory()->license()->create([ + 'file_path' => $updateFile->saveToImportsDirectory(), + ]); + + $this->importFileResponse([ + 'import' => $updateImport->id, + 'import-update' => true, + ])->assertOk(); + + $license->refresh(); + $this->assertEquals($updatedRow['orderNumber'], $license->order_number); + + $updateLog = ActivityLog::query() + ->where('item_type', License::class) + ->where('item_id', $license->id) + ->where('action_type', 'update') + ->latest('id') + ->first(); + + $this->assertNotNull($updateLog, 'Expected an update action log entry after license importer update mode.'); + } + #[Test] public function custom_column_mapping(): void { diff --git a/tests/Feature/Importing/Api/ImportLocationsTest.php b/tests/Feature/Importing/Api/ImportLocationsTest.php index 34aea6251d..09dd28e42f 100644 --- a/tests/Feature/Importing/Api/ImportLocationsTest.php +++ b/tests/Feature/Importing/Api/ImportLocationsTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Importing\Api; +use App\Models\Actionlog as ActionLog; use App\Models\Import; use App\Models\Location; use App\Models\User; @@ -129,4 +130,46 @@ class ImportLocationsTest extends ImportDataTestCase implements TestsPermissions Arr::except($updatedLocation->attributesToArray(), array_merge($updatedAttributes, $location->getDates())), ); } + + #[Test] + public function update_mode_logs_location_update_in_actionlog(): void + { + $this->actingAsForApi(User::factory()->superuser()->create()); + + $initialFile = ImportFileBuilder::new(); + $initialRow = $initialFile->firstRow(); + $initialImport = Import::factory()->locations()->create([ + 'file_path' => $initialFile->saveToImportsDirectory(), + ]); + + $this->importFileResponse(['import' => $initialImport->id])->assertOk(); + + $location = Location::query()->where('name', $initialRow['name'])->sole(); + + $updatedRow = array_merge($initialRow, [ + 'notes' => 'Importer update notes', + ]); + + $updateFile = new ImportFileBuilder([$updatedRow]); + $updateImport = Import::factory()->locations()->create([ + 'file_path' => $updateFile->saveToImportsDirectory(), + ]); + + $this->importFileResponse([ + 'import' => $updateImport->id, + 'import-update' => true, + ])->assertOk(); + + $location->refresh(); + $this->assertEquals($updatedRow['notes'], $location->notes); + + $updateLog = ActionLog::query() + ->where('item_type', Location::class) + ->where('item_id', $location->id) + ->where('action_type', 'update') + ->latest('id') + ->first(); + + $this->assertNotNull($updateLog, 'Expected an update action log entry after location importer update mode.'); + } } diff --git a/tests/Feature/Importing/Api/ImportUsersTest.php b/tests/Feature/Importing/Api/ImportUsersTest.php index c7caa307ef..55b9fd1f51 100644 --- a/tests/Feature/Importing/Api/ImportUsersTest.php +++ b/tests/Feature/Importing/Api/ImportUsersTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Importing\Api; +use App\Models\Actionlog as ActionLog; use App\Models\Asset; use App\Models\Import; use App\Models\Location; @@ -260,6 +261,48 @@ class ImportUsersTest extends ImportDataTestCase implements TestsPermissionsRequ ); } + #[Test] + public function update_mode_logs_user_update_in_actionlog(): void + { + $this->actingAsForApi(User::factory()->superuser()->create()); + + $initialFile = ImportFileBuilder::new(); + $initialRow = $initialFile->firstRow(); + $initialImport = Import::factory()->users()->create([ + 'file_path' => $initialFile->saveToImportsDirectory(), + ]); + + $this->importFileResponse(['import' => $initialImport->id])->assertOk(); + + $user = User::query()->where('username', $initialRow['username'])->sole(); + + $updatedRow = array_merge($initialRow, [ + 'position' => $initialRow['position'].' Updated', + ]); + + $updateFile = new ImportFileBuilder([$updatedRow]); + $updateImport = Import::factory()->users()->create([ + 'file_path' => $updateFile->saveToImportsDirectory(), + ]); + + $this->importFileResponse([ + 'import' => $updateImport->id, + 'import-update' => true, + ])->assertOk(); + + $user->refresh(); + $this->assertEquals($updatedRow['position'], $user->jobtitle); + + $updateLog = ActionLog::query() + ->where('item_type', User::class) + ->where('item_id', $user->id) + ->where('action_type', 'update') + ->latest('id') + ->first(); + + $this->assertNotNull($updateLog, 'Expected an update action log entry after user importer update mode.'); + } + /** * Some of these should mismatch on purpose to ensure the mapping is working */ diff --git a/tests/Feature/Importing/AssetImportCreatedAtTest.php b/tests/Feature/Importing/AssetImportCreatedAtTest.php new file mode 100644 index 0000000000..8acdf431f3 --- /dev/null +++ b/tests/Feature/Importing/AssetImportCreatedAtTest.php @@ -0,0 +1,193 @@ +create(['category_type' => 'asset']); + $location = Location::factory()->create(); + $statusLabel = Statuslabel::factory()->create(); + $company = Company::factory()->create(); + $assetModel = AssetModel::factory()->for($category, 'category')->create(); + + // Create an existing asset with a known created_at date + $originalCreatedAt = now()->subDays(30)->toDateTimeString(); + $asset = Asset::factory() + ->for($assetModel, 'model') + ->for($statusLabel, 'status') + ->for($location, 'defaultLoc') + ->for($company, 'company') + ->create([ + 'asset_tag' => 'TEST-001', + 'name' => 'Test Asset', + 'created_at' => $originalCreatedAt, + ]); + + // Create CSV content that updates the existing asset + $csv = "asset tag,item name,category,status\n"; + $csv .= "TEST-001,Test Asset Updated,{$category->name},{$statusLabel->name}\n"; + + // Perform import with update flag + $this->actingAsForApi(User::factory()->canImport()->create()) + ->postJson(route('api.imports.store'), [ + 'files' => [ + $this->createFakeUploadedFile('test.csv', $csv), + ], + ]) + ->assertSuccessful(); + + // Import the file + $import = \App\Models\Import::latest()->first(); + $this->actingAsForApi(User::factory()->canImport()->create()) + ->postJson(route('api.imports.importFile', $import->id), [ + 'import-type' => 'asset', + 'import-update' => true, + 'column-mappings' => [ + 'asset tag' => 'asset_tag', + 'item name' => 'item_name', + 'category' => 'category', + 'status' => 'status', + ], + ]) + ->assertSuccessful(); + + // Verify the asset's created_at timestamp wasn't modified + $asset->refresh(); + $this->assertEquals( + $originalCreatedAt, + $asset->created_at->toDateTimeString(), + 'Asset created_at timestamp was modified during import update, which should not happen' + ); + + // Verify the asset was updated correctly + $this->assertEquals('Test Asset Updated', $asset->name); + } + + /** + * Test that multiple successive imports don't cause timestamp drift + */ + #[Test] + public function successive_imports_maintain_created_at_consistency() + { + // Create test data + $category = Category::factory()->create(['category_type' => 'asset']); + $location = Location::factory()->create(); + $statusLabel = Statuslabel::factory()->create(); + $company = Company::factory()->create(); + $assetModel = AssetModel::factory()->for($category, 'category')->create(); + + // Create multiple existing assets + $assets = collect(); + for ($i = 1; $i <= 5; $i++) { + $assets->push(Asset::factory() + ->for($assetModel, 'model') + ->for($statusLabel, 'status') + ->for($location, 'defaultLoc') + ->for($company, 'company') + ->create([ + 'asset_tag' => "TEST-{$i}", + 'created_at' => now()->subDays(30 - $i), + ])); + } + + $originalCreatedAts = $assets->mapWithKeys(fn ($asset) => [$asset->id => $asset->created_at->toDateTimeString()])->toArray(); + + // Perform first import update + $csv = "asset tag,item name,category,status\n"; + foreach ($assets as $asset) { + $csv .= "{$asset->asset_tag},{$asset->name},{$category->name},{$statusLabel->name}\n"; + } + + $this->actingAsForApi(User::factory()->canImport()->create()) + ->postJson(route('api.imports.store'), [ + 'files' => [ + $this->createFakeUploadedFile('test1.csv', $csv), + ], + ]) + ->assertSuccessful(); + + $import1 = \App\Models\Import::latest()->first(); + $this->actingAsForApi(User::factory()->canImport()->create()) + ->postJson(route('api.imports.importFile', $import1->id), [ + 'import-type' => 'asset', + 'import-update' => true, + 'column-mappings' => [ + 'asset tag' => 'asset_tag', + 'item name' => 'item_name', + 'category' => 'category', + 'status' => 'status', + ], + ]) + ->assertSuccessful(); + + // Perform second import update + $this->actingAsForApi(User::factory()->canImport()->create()) + ->postJson(route('api.imports.store'), [ + 'files' => [ + $this->createFakeUploadedFile('test2.csv', $csv), + ], + ]) + ->assertSuccessful(); + + $import2 = \App\Models\Import::latest()->first(); + $this->actingAsForApi(User::factory()->canImport()->create()) + ->postJson(route('api.imports.importFile', $import2->id), [ + 'import-type' => 'asset', + 'import-update' => true, + 'column-mappings' => [ + 'asset tag' => 'asset_tag', + 'item name' => 'item_name', + 'category' => 'category', + 'status' => 'status', + ], + ]) + ->assertSuccessful(); + + // Verify all assets' created_at timestamps remain unchanged + foreach ($assets as $asset) { + $asset->refresh(); + $this->assertEquals( + $originalCreatedAts[$asset->id], + $asset->created_at->toDateTimeString(), + "Asset {$asset->asset_tag} created_at changed between imports" + ); + } + } + + /** + * Helper method to create a fake uploaded file + */ + protected function createFakeUploadedFile(string $filename, string $content) + { + $path = tempnam(sys_get_temp_dir(), 'csv'); + file_put_contents($path, $content); + + return new \Illuminate\Http\UploadedFile( + $path, + $filename, + 'text/csv', + null, + true + ); + } + +} +