actingAsForApi(User::factory()->create()); $this->importFileResponse(['import' => 44])->assertForbidden(); } #[Test] public function user_with_import_accessory_permission_can_import_accessories(): void { $this->actingAsForApi(User::factory()->canImport()->create()); $import = Import::factory()->accessory()->create(); $this->importFileResponse(['import' => $import->id])->assertOk(); } #[Test] public function import_accessory(): void { $importFileBuilder = ImportFileBuilder::new(); $row = $importFileBuilder->firstRow(); $import = Import::factory()->accessory()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]); $this->actingAsForApi(User::factory()->superuser()->create()); $this->importFileResponse(['import' => $import->id]) ->assertOk() ->assertExactJson([ 'payload' => ['tally' => ['created' => 1, 'updated' => 0, 'skipped' => 0, 'errored' => 0]], 'status' => 'success', 'messages' => [ 'redirect_url' => route('accessories.index'), ], ]); $newAccessory = Accessory::query() ->with(['location', 'category', 'manufacturer', 'supplier', 'company']) ->where('name', $row['itemName']) ->sole(); $activityLog = Actionlog::query() ->where('item_type', Accessory::class) ->where('item_id', $newAccessory->id) ->sole(); $this->assertEquals('create', $activityLog->action_type); $this->assertEquals('importer', $activityLog->action_source); $this->assertEquals($newAccessory->company->id, $activityLog->company_id); $this->assertEquals($row['itemName'], $newAccessory->name); $this->assertEquals($row['quantity'], $newAccessory->qty); // supplier + order_number + purchase_date + purchase_cost all // live on the Orders / OrderItems polymorphic pair now — the // importer's recordOrderForImportedRow helper writes them there. $orderItem = $newAccessory->orderItems()->firstOrFail(); $this->assertEquals($row['orderNumber'], $orderItem->order->order_number); $this->assertEquals($row['purchaseDate'], $orderItem->order->purchase_date->toDateString()); $this->assertEquals((float) $row['purchaseCost'], (float) $orderItem->price); $this->assertEquals($row['supplierName'], $orderItem->order->supplier->name); $this->assertEquals($row['notes'], $newAccessory->notes); $this->assertEquals($row['category'], $newAccessory->category->name); $this->assertEquals('accessory', $newAccessory->category->category_type); $this->assertEquals($row['manufacturerName'], $newAccessory->manufacturer->name); $this->assertEquals($row['location'], $newAccessory->location->name); $this->assertEquals($row['companyName'], $newAccessory->company->name); $this->assertEquals($row['modelNumber'], $newAccessory->model_number); $this->assertFalse($newAccessory->requestable); $this->assertNull($newAccessory->min_amt); $this->assertNull($newAccessory->user_id); } #[Test] public function when_import_file_contains_unknown_columns(): void { $row = ImportFileBuilder::new()->definition(); $row['unknownColumn'] = $this->faker->word; $importFileBuilder = new ImportFileBuilder([$row]); $this->actingAsForApi(User::factory()->superuser()->create()); $import = Import::factory()->accessory()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]); $this->importFileResponse(['import' => $import->id])->assertOk(); } #[Test] public function will_format_date(): void { $importFileBuilder = ImportFileBuilder::new(['purchaseDate' => '2022/10/10']); $import = Import::factory()->accessory()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]); $this->actingAsForApi(User::factory()->superuser()->create()); $this->importFileResponse(['import' => $import->id])->assertOk(); // purchase_date landed on the OrderItem's Order rather than the // parent Accessory column — assert that the importer's date // parser normalized the slashed CSV input to a Y-m-d value. $accessory = Accessory::query() ->where('name', $importFileBuilder->firstRow()['itemName']) ->sole(); $order = $accessory->orderItems()->latest('id')->firstOrFail()->order; $this->assertEquals('2022-10-10', $order->purchase_date->toDateString()); } #[Test] public function will_not_create_new_category_when_category_exists(): void { $importFileBuilder = ImportFileBuilder::times(4)->replace(['category' => Str::random()]); $import = Import::factory()->accessory()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]); $this->actingAsForApi(User::factory()->superuser()->create()); $this->importFileResponse(['import' => $import->id])->assertOk(); $newAccessories = Accessory::query() ->whereIn('name', $importFileBuilder->pluck('itemName')) ->get(); $this->assertCount(1, $newAccessories->pluck('category_id')->unique()->all()); } #[Test] public function will_not_create_new_accessory_when_accessory_with_name_exists(): void { $accessory = Accessory::factory()->create(['name' => Str::random()]); $importFileBuilder = ImportFileBuilder::times(2)->replace(['itemName' => $accessory->name]); $import = Import::factory()->accessory()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]); $this->actingAsForApi(User::factory()->superuser()->create()); $this->importFileResponse(['import' => $import->id])->assertOk(); $probablyNewAccessories = Accessory::query() ->where('name', $importFileBuilder->pluck('itemName')) ->get(['name']); $this->assertCount(1, $probablyNewAccessories); $this->assertEquals($accessory->name, $probablyNewAccessories->first()->name); } #[Test] public function will_not_create_new_company_when_company_already_exists(): void { $importFileBuilder = ImportFileBuilder::times(4)->replace(['companyName' => Str::random()]); $import = Import::factory()->accessory()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]); $this->actingAsForApi(User::factory()->superuser()->create()); $this->importFileResponse(['import' => $import->id])->assertOk(); $newAccessories = Accessory::query() ->where('name', $importFileBuilder->pluck('itemName')) ->get(['company_id']); $this->assertCount(1, $newAccessories->pluck('company_id')->unique()->all()); } #[Test] public function will_not_create_new_location_when_location_already_exists(): void { $importFileBuilder = ImportFileBuilder::times(4)->replace(['location' => Str::random()]); $import = Import::factory()->accessory()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]); $this->actingAsForApi(User::factory()->superuser()->create()); $this->importFileResponse(['import' => $import->id])->assertOk(); $newAccessories = Accessory::query() ->where('name', $importFileBuilder->pluck('itemName')) ->get(['location_id']); $this->assertCount(1, $newAccessories->pluck('location_id')->unique()->all()); } #[Test] public function will_not_create_new_manufacturer_when_manufacturer_already_exists(): void { $importFileBuilder = ImportFileBuilder::times(4)->replace(['manufacturerName' => $this->faker->company]); $import = Import::factory()->accessory()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]); $this->actingAsForApi(User::factory()->superuser()->create()); $this->importFileResponse(['import' => $import->id])->assertOk(); $newAccessories = Accessory::query() ->where('name', $importFileBuilder->pluck('itemName')) ->get(['manufacturer_id']); $this->assertCount(1, $newAccessories->pluck('manufacturer_id')->unique()->all()); } #[Test] public function will_not_create_new_supplier_when_supplier_already_exists(): void { $importFileBuilder = ImportFileBuilder::times(4)->replace(['supplierName' => $this->faker->company]); $import = Import::factory()->accessory()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]); $this->actingAsForApi(User::factory()->superuser()->create()); $this->importFileResponse(['import' => $import->id])->assertOk(); // Supplier reuse across imported rows is observable on the // OrderItem's Order.supplier_id (parent's default_supplier_id // gets seeded from the first-row's supplier for these but the // per-row dedupe rule lives on the Order path). $newAccessories = Accessory::query() ->whereIn('name', $importFileBuilder->pluck('itemName')) ->get(); $supplierIds = $newAccessories->map( fn ($accessory) => $accessory->orderItems()->latest('id')->first()?->order?->supplier_id, )->filter()->unique()->all(); $this->assertCount(1, $supplierIds); } #[Test] public function when_columns_are_missing_in_import_file(): void { $importFileBuilder = ImportFileBuilder::new()->forget(['minimumAmount', 'purchaseCost', 'purchaseDate']); $import = Import::factory()->accessory()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]); $this->actingAsForApi(User::factory()->superuser()->create()); $this->importFileResponse(['import' => $import->id])->assertOk(); $newAccessory = Accessory::query() ->where('name', $importFileBuilder->firstRow()['itemName']) ->sole(); $this->assertNull($newAccessory->min_amt); // purchase_date / purchase_cost columns are gone from the parent; // when the CSV omits them, the observer-written OrderItem has // null price and its Order has null purchase_date. $orderItem = $newAccessory->orderItems()->latest('id')->firstOrFail(); $this->assertNull($orderItem->order->purchase_date); $this->assertNull($orderItem->price); } #[Test] public function when_required_columns_are_missing_in_import_file(): void { $importFileBuilder = ImportFileBuilder::new()->forget(['itemName', 'category']); $import = Import::factory()->accessory()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]); $this->actingAsForApi(User::factory()->superuser()->create()); $this->importFileResponse(['import' => $import->id]) ->assertInternalServerError() ->assertExactJson([ 'status' => 'import-errors', 'payload' => ['tally' => ['created' => 0, 'updated' => 0, 'skipped' => 0, 'errored' => 1]], 'messages' => [ '' => [ 'Accessory' => [ 'category_id' => ['The category id field is required.'], 'name' => ['The name field is required.'], ], ], ], ]); } #[Test] public function update_accessory_from_import(): void { $accessory = Accessory::factory()->create(['name' => Str::random()])->refresh(); $importFileBuilder = ImportFileBuilder::new(['itemName' => $accessory->name]); $row = $importFileBuilder->firstRow(); $import = Import::factory()->accessory()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]); $this->actingAsForApi(User::factory()->superuser()->create()); $this->importFileResponse(['import' => $import->id, 'import-update' => true])->assertOk(); $updatedAccessory = Accessory::query()->find($accessory->id); $updatedAttributes = [ 'name', 'company_id', 'qty', 'default_purchase_cost', 'default_supplier_id', 'notes', 'category_id', 'manufacturer_id', 'location_id', 'model_number', 'updated_at', ]; $this->assertEquals($row['itemName'], $updatedAccessory->name); $this->assertEquals($row['companyName'], $updatedAccessory->company->name); $this->assertEquals($row['quantity'], $updatedAccessory->qty); // Acquisition metadata (order_number / purchase_date / // purchase_cost / supplier) lives on the latest OrderItem's // Order, not the parent. Update mode writes a fresh Order + // OrderItem via recordOrderForImportedRow when the CSV carries // acquisition columns. (When qty differs, the value also rides // on the QuantityAdjust log — see the sibling test // importer_qty_change_creates_quantity_adjust_log.) $latestOrderItem = $updatedAccessory->orderItems()->latest('id')->firstOrFail(); $this->assertEquals($row['purchaseDate'], $latestOrderItem->order->purchase_date->toDateString()); $this->assertEquals((float) $row['purchaseCost'], (float) $latestOrderItem->price); $this->assertEquals($row['supplierName'], $latestOrderItem->order->supplier->name); $this->assertEquals($row['notes'], $updatedAccessory->notes); $this->assertEquals($row['category'], $updatedAccessory->category->name); $this->assertEquals('accessory', $updatedAccessory->category->category_type); $this->assertEquals($row['manufacturerName'], $updatedAccessory->manufacturer->name); $this->assertEquals($row['location'], $updatedAccessory->location->name); $this->assertEquals($row['modelNumber'], $updatedAccessory->model_number); $this->assertEquals( Arr::except($accessory->attributesToArray(), $updatedAttributes), Arr::except($updatedAccessory->attributesToArray(), $updatedAttributes), ); } #[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(); // Change `notes` (a plain fillable field). orderNumber is // intentionally NOT the trigger here because // ItemImporter::applyUpdateWithQtyAdjust strips order_number from // the update payload, so a notes-only diff is what proves the // update-log path still fires. $updatedRow = array_merge($initialRow, [ 'notes' => (string) ($initialRow['notes'] ?? '').' updated', ]); $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['notes'], $accessory->notes); $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 update_mode_clears_field_when_csv_column_is_present_but_empty(): void { $this->actingAsForApi(User::factory()->superuser()->create()); $accessory = Accessory::factory()->create([ 'notes' => 'Some pre-existing notes', ])->refresh(); $this->assertNotEmpty($accessory->notes); // purchase_date moved off the parent post-Orders — the // "empty CSV cell clears the DB column" behavior for the // parent-owned columns is covered here by notes alone. $row = ImportFileBuilder::new()->definition(); $row['itemName'] = $accessory->name; $row['notes'] = ''; $importFileBuilder = new ImportFileBuilder([$row]); $import = Import::factory()->accessory()->create([ 'file_path' => $importFileBuilder->saveToImportsDirectory(), ]); $this->importFileResponse([ 'import' => $import->id, 'import-update' => true, ])->assertOk(); $accessory->refresh(); $this->assertNull($accessory->notes); } #[Test] public function update_mode_preserves_fields_when_csv_column_is_absent(): void { $this->actingAsForApi(User::factory()->superuser()->create()); $accessory = Accessory::factory()->create([ 'notes' => 'Do not lose this', ])->refresh(); $originalNotes = $accessory->notes; // Import a CSV that only has the identity field (name) plus one // updated column. All other parent columns absent from the CSV // must be preserved on update. model_number is the "changed // field" proxy; notes is the "preserved field" proxy. $partialFile = new ImportFileBuilder([[ 'itemName' => $accessory->name, 'modelNumber' => 'UPDATED-MODEL-NUMBER', ]]); $partialImport = Import::factory()->accessory()->create([ 'file_path' => $partialFile->saveToImportsDirectory(), ]); $this->importFileResponse([ 'import' => $partialImport->id, 'import-update' => true, ])->assertOk(); $accessory->refresh(); $this->assertEquals('UPDATED-MODEL-NUMBER', $accessory->model_number); $this->assertEquals($originalNotes, $accessory->notes); } #[Test] public function when_import_file_contains_empty_values(): void { $accessory = Accessory::factory()->create(['name' => Str::random()]); $accessory->refresh(); $importFileBuilder = ImportFileBuilder::new([ 'companyName' => ' ', 'purchaseDate' => ' ', 'purchaseCost' => '', 'location' => '', 'orderNumber' => '', 'category' => '', 'quantity' => '', 'manufacturerName' => '', 'supplierName' => '', 'notes' => '', 'requestAble' => '', 'minimumAmount' => '', 'modelNumber' => '', ]); $import = Import::factory()->accessory()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]); $this->actingAsForApi(User::factory()->superuser()->create()); $this->importFileResponse(['import' => $import->id]) ->assertInternalServerError() ->assertExactJson([ 'status' => 'import-errors', 'payload' => ['tally' => ['created' => 0, 'updated' => 0, 'skipped' => 0, 'errored' => 1]], 'messages' => [ $importFileBuilder->firstRow()['itemName'] => [ 'Accessory' => [ 'category_id' => ['The category id field is required.'], ], ], ], ]); $importFileBuilder->replace(['itemName' => $accessory->name]); $import = Import::factory()->accessory()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]); $this->importFileResponse(['import' => $import->id, 'import-update' => true])->assertOk(); $updatedAccessory = clone $accessory; $updatedAccessory->refresh(); $this->assertEquals($accessory->toArray(), $updatedAccessory->toArray()); } #[Test] public function custom_column_mapping(): void { $faker = ImportFileBuilder::new()->definition(); $row = [ 'itemName' => $faker['modelNumber'], 'purchaseDate' => $faker['notes'], 'purchaseCost' => $faker['location'], 'location' => $faker['purchaseCost'], 'companyName' => $faker['orderNumber'], 'orderNumber' => $faker['companyName'], 'category' => $faker['manufacturerName'], 'manufacturerName' => $faker['category'], 'notes' => $faker['purchaseDate'], 'minimumAmount' => $faker['supplierName'], 'modelNumber' => $faker['itemName'], 'quantity' => $faker['quantity'], ]; $importFileBuilder = new ImportFileBuilder([$row]); $import = Import::factory()->accessory()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]); $this->actingAsForApi(User::factory()->superuser()->create()); $this->importFileResponse([ 'import' => $import->id, 'column-mappings' => [ 'Item Name' => 'model_number', 'Purchase Date' => 'notes', 'Purchase Cost' => 'location', 'Location' => 'purchase_cost', 'Company' => 'order_number', 'Order Number' => 'company', 'Category' => 'manufacturer', 'Manufacturer' => 'category', 'Supplier' => 'min_amt', 'Notes' => 'purchase_date', 'Min QTY' => 'supplier', 'Model Number' => 'item_name', 'Quantity' => 'quantity', ], ])->assertOk(); $newAccessory = Accessory::query() ->with(['location', 'category', 'manufacturer']) ->where('name', $row['modelNumber']) ->sole(); // purchase_date, purchase_cost, and supplier moved off the // parent to the Orders / OrderItems polymorphic pair — // recordOrderForImportedRow persists them there. Assertions // walk through orderItems.order for those three fields. $this->assertEquals($row['modelNumber'], $newAccessory->name); $this->assertEquals($row['itemName'], $newAccessory->model_number); $this->assertEquals($row['quantity'], $newAccessory->qty); $orderItem = $newAccessory->orderItems()->firstOrFail(); $this->assertEquals($row['notes'], $orderItem->order->purchase_date->toDateString()); $this->assertEquals($row['location'], (float) $orderItem->price); // See the import_accessory test above for why order_number now // lives on Orders / OrderItems rather than the parent column. // Note this custom-mapping test intentionally maps companyName // to the orderNumber CSV column, verifying that whatever value // that column carries lands on the OrderItem's Order regardless // of what the source column was called. $this->assertEquals($row['companyName'], $orderItem->order->order_number); $this->assertEquals($row['purchaseDate'], $newAccessory->notes); $this->assertEquals($row['manufacturerName'], $newAccessory->category->name); $this->assertEquals($row['category'], $newAccessory->manufacturer->name); $this->assertEquals($row['purchaseCost'], $newAccessory->location->name); } #[Test] public function accessory_import_checks_out_to_user_when_username_matches(): void { $actor = User::factory()->superuser()->create(); $target = User::factory()->create(['username' => 'accessorytarget']); // Hand-crafted CSV: needs the checkout columns // (checkout_class + username) alongside the standard accessory // columns, which the AccessoriesImportFileBuilder doesn't include. $csv = "Item Name,Category,Quantity,Company,Checkout Type,Username\n" .'CSV-Checked-Accessory,Cables,5,CSVCo,user,'.$target->username."\n"; $filename = 'accessory-checkout-'.uniqid().'.csv'; file_put_contents(config('app.private_uploads').'/imports/'.$filename, $csv); try { $import = Import::factory()->accessory()->create(['file_path' => $filename]); $this->actingAsForApi($actor); $this->importFileResponse(['import' => $import->id])->assertOk(); $accessory = Accessory::query()->where('name', 'CSV-Checked-Accessory')->sole(); $this->assertEquals(5, $accessory->qty); $this->assertDatabaseHas('accessories_checkout', [ 'accessory_id' => $accessory->id, 'assigned_to' => $target->id, 'assigned_type' => User::class, ]); } finally { @unlink(config('app.private_uploads').'/imports/'.$filename); } } #[Test] public function accessory_import_without_checkout_columns_does_not_create_checkout(): void { $actor = User::factory()->superuser()->create(); $csv = "Item Name,Category,Quantity,Company\n" ."CSV-Uncheckedout-Accessory,Cables,3,CSVCo\n"; $filename = 'accessory-nocheckout-'.uniqid().'.csv'; file_put_contents(config('app.private_uploads').'/imports/'.$filename, $csv); try { $import = Import::factory()->accessory()->create(['file_path' => $filename]); $this->actingAsForApi($actor); $this->importFileResponse(['import' => $import->id])->assertOk(); $accessory = Accessory::query()->where('name', 'CSV-Uncheckedout-Accessory')->sole(); $this->assertDatabaseMissing('accessories_checkout', [ 'accessory_id' => $accessory->id, ]); } finally { @unlink(config('app.private_uploads').'/imports/'.$filename); } } }