From 8306b7e747e7f915da2f5cf1b5d1ab5d4fd65f24 Mon Sep 17 00:00:00 2001 From: swdee Date: Sun, 12 Jul 2026 22:45:21 +1200 Subject: [PATCH] added tests for Export as CSV controller and exporter code --- tests/Controller/ProjectControllerTest.php | 558 ++++++++++++++++++ .../ProjectBomExporterTest.php | 377 ++++++++++++ 2 files changed, 935 insertions(+) create mode 100644 tests/Controller/ProjectControllerTest.php create mode 100644 tests/Services/ImportExportSystem/ProjectBomExporterTest.php diff --git a/tests/Controller/ProjectControllerTest.php b/tests/Controller/ProjectControllerTest.php new file mode 100644 index 00000000..938cd71e --- /dev/null +++ b/tests/Controller/ProjectControllerTest.php @@ -0,0 +1,558 @@ + + */ + private array $entitiesToRemove = []; + + protected function setUp(): void + { + $this->client = static::createClient(); + + $this->entityManager = self::getContainer()->get( + EntityManagerInterface::class + ); + + $user = $this->entityManager + ->getRepository(User::class) + ->findOneBy(['name' => 'admin']); + + $this->assertInstanceOf( + User::class, + $user, + 'The admin fixture user was not found.' + ); + + $this->client->loginUser($user); + $this->client->catchExceptions(false); + } + + protected function tearDown(): void + { + foreach (array_reverse($this->entitiesToRemove) as $entity) { + if ($this->entityManager->contains($entity)) { + $this->entityManager->remove($entity); + } + } + + $this->entityManager->flush(); + $this->entitiesToRemove = []; + + parent::tearDown(); + } + + public function testExportBOMAsCSV(): void + { + $project = $this->createProject('Controller CSV Export Test'); + + $this->createBOMEntry( + $project, + '10k resistor', + 'R1,R2', + 2.0, + 'Generic resistor' + ); + + $this->createBOMEntry( + $project, + '100nF capacitor', + 'C1', + 1.0, + 'Ceramic capacitor' + ); + + $this->entityManager->flush(); + + $this->client->request( + 'POST', + sprintf( + '/en/project/%d/bom/export', + $project->getID() + ), + $this->getDataTableRequest([ + 'quantity', + 'name', + 'description', + 'mountnames', + ], [ + 'BOM Qty.', + 'Name', + 'Description', + 'Mount names', + ]) + ); + + $response = $this->client->getResponse(); + + $this->assertTrue( + $response->isSuccessful(), + sprintf( + 'The CSV export request failed with HTTP %d.', + $response->getStatusCode() + ) + ); + + $this->assertSame( + 'text/csv; charset=UTF-8', + $response->headers->get('Content-Type') + ); + + $contentDisposition = (string) $response->headers->get( + 'Content-Disposition' + ); + + $this->assertStringContainsString( + 'attachment', + $contentDisposition + ); + + $this->assertStringContainsString( + '.csv', + $contentDisposition + ); + + $content = $response->getContent(); + + $this->assertNotFalse($content); + + /* + * Parse the generated CSV so the test verifies the exported data rather + * than depending on the exact quoting produced by Symfony's CSV encoder. + */ + $stream = fopen('php://memory', 'r+'); + + $this->assertIsResource($stream); + + fwrite($stream, $content); + rewind($stream); + + $header = fgetcsv( + $stream, + separator: ';', + enclosure: '"', + escape: '' + ); + + fclose($stream); + + $this->assertSame( + [ + 'BOM Qty.', + 'Name', + 'Description', + 'Mount names', + ], + $header + ); + + $this->assertStringContainsString('10k resistor', $content); + $this->assertStringContainsString('Generic resistor', $content); + $this->assertStringContainsString('R1, R2', $content); + $this->assertStringContainsString('100nF capacitor', $content); + $this->assertStringContainsString('Ceramic capacitor', $content); + } + + public function testExportBOMIgnoresPagination(): void + { + $project = $this->createProject( + 'Controller Unpaginated CSV Export Test' + ); + + for ($index = 1; $index <= 11; ++$index) { + $this->createBOMEntry( + $project, + sprintf('Pagination test part %02d', $index), + sprintf('R%d', $index), + 1.0, + '' + ); + } + + $this->entityManager->flush(); + + $request = $this->getDataTableRequest( + ['name'], + ['Name'] + ); + + /* + * Simulate the browser currently displaying the second page with + * ten rows per page. The controller must override these values. + */ + $request['start'] = 10; + $request['length'] = 10; + + $this->client->request( + 'POST', + sprintf( + '/en/project/%d/bom/export', + $project->getID() + ), + $request + ); + + $response = $this->client->getResponse(); + + $this->assertTrue( + $response->isSuccessful(), + sprintf( + 'The CSV export request failed with HTTP %d.', + $response->getStatusCode() + ) + ); + + $content = $response->getContent(); + + $this->assertNotFalse($content); + + $rows = preg_split('/\R/', trim($content)); + + $this->assertIsArray($rows); + + $rows = array_values(array_filter( + $rows, + static fn(string $row): bool => $row !== '' + )); + + /* + * One heading row plus all eleven BOM entries. If pagination were + * applied, only one data row from the second page would be present. + */ + $this->assertCount(12, $rows); + + $this->assertStringContainsString( + 'Pagination test part 01', + $content + ); + + $this->assertStringContainsString( + 'Pagination test part 11', + $content + ); + } + + public function testExportBOMPreservesCurrentSortOrder(): void + { + $project = $this->createProject( + 'Controller Sorted CSV Export Test' + ); + + $this->createBOMEntry( + $project, + 'Lower quantity component', + 'U1', + 1.0, + '' + ); + + $this->createBOMEntry( + $project, + 'Higher quantity component', + 'U2', + 5.0, + '' + ); + + $this->entityManager->flush(); + + $request = $this->getDataTableRequest( + [ + 'quantity', + 'name', + ], + [ + 'BOM Qty.', + 'Name', + ] + ); + + /* + * Quantity is column index 2 in ProjectBomEntriesDataTable. + */ + $request['order'] = [ + [ + 'column' => 2, + 'dir' => 'desc', + 'name' => '', + ], + ]; + + $this->client->request( + 'POST', + sprintf( + '/en/project/%d/bom/export', + $project->getID() + ), + $request + ); + + $response = $this->client->getResponse(); + + $this->assertTrue( + $response->isSuccessful(), + sprintf( + 'The CSV export request failed with HTTP %d.', + $response->getStatusCode() + ) + ); + + $content = $response->getContent(); + + $this->assertNotFalse($content); + + $higherPosition = strpos( + $content, + 'Higher quantity component' + ); + + $lowerPosition = strpos( + $content, + 'Lower quantity component' + ); + + $this->assertNotFalse($higherPosition); + $this->assertNotFalse($lowerPosition); + + $this->assertLessThan( + $lowerPosition, + $higherPosition, + 'The CSV did not preserve descending quantity order.' + ); + } + + public function testExportBOMRequiresExportColumns(): void + { + $project = $this->createProject( + 'Controller Missing Columns CSV Export Test' + ); + + $this->createBOMEntry( + $project, + 'Missing columns test entry', + 'U1', + 1.0, + '' + ); + + $this->entityManager->flush(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'No columns were specified for BOM export.' + ); + + $this->client->request( + 'POST', + sprintf( + '/en/project/%d/bom/export', + $project->getID() + ), + [ + '_dt' => 'dt', + 'draw' => 1, + 'start' => 0, + 'length' => 10, + 'search' => [ + 'value' => '', + 'regex' => false, + ], + ] + ); + } + + private function createProject(string $name): Project + { + $project = new Project(); + $project->setName($name); + + $this->entityManager->persist($project); + $this->entitiesToRemove[] = $project; + + return $project; + } + + private function createBOMEntry( + Project $project, + string $name, + string $mountnames, + float $quantity, + string $comment, + ): ProjectBOMEntry { + $entry = new ProjectBOMEntry(); + $entry->setProject($project); + $entry->setName($name); + $entry->setMountnames($mountnames); + $entry->setQuantity($quantity); + $entry->setComment($comment); + + $this->entityManager->persist($entry); + $this->entitiesToRemove[] = $entry; + + return $entry; + } + + /** + * Build the DataTables state sent by the browser. + * + * The table callback uses the configured server-side column definitions, + * but order indexes refer to the complete ProjectBomEntriesDataTable. + * + * @param list $exportColumns + * @param list $exportLabels + * + * @return array + */ + private function getDataTableRequest( + array $exportColumns, + array $exportLabels, + ): array { + return [ + '_dt' => 'dt', + 'draw' => 1, + 'start' => 0, + 'length' => 10, + 'columns' => [ + $this->getColumnRequest( + 'picture', + false, + false + ), + $this->getColumnRequest( + 'id', + true, + true + ), + $this->getColumnRequest( + 'quantity', + true, + true + ), + $this->getColumnRequest( + 'partId', + false, + true + ), + $this->getColumnRequest( + 'name', + true, + true + ), + $this->getColumnRequest( + 'ipn', + false, + true + ), + $this->getColumnRequest( + 'description', + false, + false + ), + $this->getColumnRequest( + 'category', + true, + true + ), + $this->getColumnRequest( + 'footprint', + true, + true + ), + $this->getColumnRequest( + 'manufacturer', + true, + true + ), + $this->getColumnRequest( + 'manufacturing_status', + false, + true + ), + $this->getColumnRequest( + 'mountnames', + true, + true + ), + $this->getColumnRequest( + 'instockAmount', + false, + false + ), + $this->getColumnRequest( + 'storelocation', + false, + true + ), + $this->getColumnRequest( + 'price', + true, + true + ), + $this->getColumnRequest( + 'ext_price', + false, + false + ), + $this->getColumnRequest( + 'addedDate', + true, + true + ), + $this->getColumnRequest( + 'lastModified', + true, + true + ), + ], + 'order' => [ + [ + 'column' => 4, + 'dir' => 'asc', + 'name' => '', + ], + ], + 'search' => [ + 'value' => '', + 'regex' => false, + ], + 'exportColumns' => $exportColumns, + 'exportLabels' => $exportLabels, + ]; + } + + /** + * @return array + */ + private function getColumnRequest( + string $data, + bool $searchable, + bool $orderable, + ): array { + return [ + 'data' => $data, + 'name' => '', + 'searchable' => $searchable, + 'orderable' => $orderable, + 'search' => [ + 'value' => '', + 'regex' => false, + ], + ]; + } +} diff --git a/tests/Services/ImportExportSystem/ProjectBomExporterTest.php b/tests/Services/ImportExportSystem/ProjectBomExporterTest.php new file mode 100644 index 00000000..03acbd7d --- /dev/null +++ b/tests/Services/ImportExportSystem/ProjectBomExporterTest.php @@ -0,0 +1,377 @@ + + */ + private array $entitiesToRemove = []; + + protected function setUp(): void + { + self::bootKernel(); + + $this->service = self::getContainer()->get(ProjectBomExporter::class); + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); + } + + protected function tearDown(): void + { + foreach (array_reverse($this->entitiesToRemove) as $entity) { + if ($this->entityManager->contains($entity)) { + $this->entityManager->remove($entity); + } + } + + $this->entityManager->flush(); + $this->entitiesToRemove = []; + + parent::tearDown(); + } + + public function testGetOrderedEntriesWithEmptyIDList(): void + { + $project = new Project(); + $project->setName('Empty BOM export test'); + + $this->assertSame( + [], + $this->service->getOrderedEntries($project, []) + ); + } + + public function testGetOrderedEntriesPreservesRequestedOrder(): void + { + $project = $this->createProject('Ordered BOM export test'); + + $first = $this->createBOMEntry( + $project, + 'First export entry', + 'R1', + 1.0, + 'First comment' + ); + + $second = $this->createBOMEntry( + $project, + 'Second export entry', + 'R2', + 2.0, + 'Second comment' + ); + + $this->entityManager->flush(); + + $result = $this->service->getOrderedEntries( + $project, + [ + $second->getID(), + $first->getID(), + ] + ); + + $this->assertCount(2, $result); + $this->assertSame($second->getID(), $result[0]->getID()); + $this->assertSame($first->getID(), $result[1]->getID()); + } + + public function testGetOrderedEntriesIgnoresEntriesFromAnotherProject(): void + { + $firstProject = $this->createProject('First BOM export project'); + $secondProject = $this->createProject('Second BOM export project'); + + $firstEntry = $this->createBOMEntry( + $firstProject, + 'First project entry', + 'R1', + 1.0, + '' + ); + + $secondEntry = $this->createBOMEntry( + $secondProject, + 'Second project entry', + 'R2', + 1.0, + '' + ); + + $this->entityManager->flush(); + + $result = $this->service->getOrderedEntries( + $firstProject, + [ + $secondEntry->getID(), + $firstEntry->getID(), + ] + ); + + $this->assertCount(1, $result); + $this->assertSame($firstEntry->getID(), $result[0]->getID()); + } + + public function testCreateRowForUnlinkedBOMEntry(): void + { + $entry = new ProjectBOMEntry(); + $entry->setName('10k resistor'); + $entry->setComment('Generic resistor used for testing'); + $entry->setMountnames('R1,R2, R3,,'); + $entry->setQuantity(3.0); + + $row = $this->service->createRow( + $entry, + [ + 'quantity', + 'partId', + 'name', + 'ipn', + 'description', + 'category', + 'footprint', + 'manufacturer', + 'manufacturing_status', + 'mountnames', + 'instockAmount', + 'storelocation', + 'addedDate', + 'lastModified', + 'unknown_column', + ], + [ + 'BOM Qty.', + 'Part ID', + 'Name', + 'IPN', + 'Description', + 'Category', + 'Footprint', + 'Manufacturer', + 'Manufacturing status', + 'Mount names', + 'In stock', + 'Storage locations', + 'Created at', + 'Last modified', + ] + ); + + $this->assertSame(3.0, $row['BOM Qty.']); + $this->assertNull($row['Part ID']); + $this->assertSame('10k resistor', $row['Name']); + $this->assertSame('', $row['IPN']); + $this->assertSame( + 'Generic resistor used for testing', + $row['Description'] + ); + $this->assertSame('', $row['Category']); + $this->assertSame('', $row['Footprint']); + $this->assertSame('', $row['Manufacturer']); + $this->assertSame('', $row['Manufacturing status']); + $this->assertSame('R1, R2, R3', $row['Mount names']); + $this->assertSame('', $row['In stock']); + $this->assertSame('', $row['Storage locations']); + $this->assertSame('', $row['Created at']); + $this->assertSame('', $row['Last modified']); + + /* + * No corresponding label was supplied for this column, so the + * exporter falls back to the column name. + */ + $this->assertArrayHasKey('unknown_column', $row); + $this->assertSame('', $row['unknown_column']); + } + + public function testCreateRowForLinkedPart(): void + { + $category = $this->getDefaultCategory(); + + $part = new Part(); + $part->setName('RC0402FR-071ML'); + $part->setDescription('10k ohm resistor'); + $part->setIpn('TEST-IPN-001'); + $part->setCategory($category); + + $project = $this->createProject('Linked part BOM export test'); + + $entry = new ProjectBOMEntry(); + $entry->setProject($project); + $entry->setPart($part); + $entry->setName('10k ohm resistor'); + $entry->setComment('BOM entry comment'); + $entry->setMountnames('R1,R2'); + $entry->setQuantity(2.0); + + $this->entityManager->persist($part); + $this->entityManager->persist($entry); + + $this->entitiesToRemove[] = $entry; + $this->entitiesToRemove[] = $part; + + $this->entityManager->flush(); + + $row = $this->service->createRow( + $entry, + [ + 'id', + 'quantity', + 'partId', + 'name', + 'ipn', + 'description', + 'category', + 'footprint', + 'manufacturer', + 'manufacturing_status', + 'mountnames', + 'instockAmount', + 'storelocation', + 'price', + 'ext_price', + 'addedDate', + 'lastModified', + ], + [ + 'BOM ID', + 'BOM Qty.', + 'Part ID', + 'Name', + 'IPN', + 'Description', + 'Category', + 'Footprint', + 'Manufacturer', + 'Manufacturing status', + 'Mount names', + 'In stock', + 'Storage locations', + 'Price', + 'Extended Price', + 'Created at', + 'Last modified', + ] + ); + + $this->assertSame($entry->getID(), $row['BOM ID']); + $this->assertSame($part->getID(), $row['Part ID']); + $this->assertSame( + 'RC0402FR-071ML 10k ohm resistor', + $row['Name'] + ); + $this->assertSame('TEST-IPN-001', $row['IPN']); + $this->assertSame('10k ohm resistor', $row['Description']); + $this->assertSame($category->getFullPath(), $row['Category']); + $this->assertSame('', $row['Footprint']); + $this->assertSame('', $row['Manufacturer']); + $this->assertSame('', $row['Manufacturing status']); + $this->assertSame('R1, R2', $row['Mount names']); + $this->assertSame('', $row['Storage locations']); + + /* + * The exact formatting depends on the configured part unit and + * currency, so verify that the formatter produced scalar strings. + */ + $this->assertIsString($row['BOM Qty.']); + $this->assertIsString($row['In stock']); + $this->assertIsString($row['Price']); + $this->assertIsString($row['Extended Price']); + + $this->assertNotSame('', $row['Created at']); + $this->assertNotSame('', $row['Last modified']); + } + + public function testCreateRowUsesColumnNameWhenLabelIsMissing(): void + { + $entry = new ProjectBOMEntry(); + $entry->setName('Label fallback test'); + $entry->setComment(''); + $entry->setMountnames('U1'); + $entry->setQuantity(1.0); + + $row = $this->service->createRow( + $entry, + [ + 'name', + 'mountnames', + ], + [ + 'Component', + ] + ); + + $this->assertSame('Label fallback test', $row['Component']); + $this->assertSame('U1', $row['mountnames']); + } + + private function createProject(string $name): Project + { + $project = new Project(); + $project->setName($name); + + $this->entityManager->persist($project); + $this->entitiesToRemove[] = $project; + + return $project; + } + + private function createBOMEntry( + Project $project, + string $name, + string $mountnames, + float $quantity, + string $comment, + ): ProjectBOMEntry { + $entry = new ProjectBOMEntry(); + $entry->setProject($project); + $entry->setName($name); + $entry->setMountnames($mountnames); + $entry->setQuantity($quantity); + $entry->setComment($comment); + + $this->entityManager->persist($entry); + $this->entitiesToRemove[] = $entry; + + return $entry; + } + + private function getDefaultCategory(): Category + { + $category = $this->entityManager + ->getRepository(Category::class) + ->findOneBy([]); + + if ($category instanceof Category) { + return $category; + } + + $category = new Category(); + $category->setName('BOM Export Test Category'); + + $this->entityManager->persist($category); + $this->entitiesToRemove[] = $category; + $this->entityManager->flush(); + + return $category; + } +}