Part-DB-server/tests/Controller/ProjectControllerTest.php
swdee be03d1f503
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
Add CSV export for Project BOM tables (#1442)
* add Export AS CSV button to the Project BOM Entry tab

* added Export as CSV translation

* add service file for BOM exporter

* added controller for handling exporting project BOM data as CSV file

* added tests for Export as CSV controller and exporter code

* remove hierarchical paths from being exported in CSV to match datatable format

* Fixed message id

* Use stimulus functions to refer to stimulus controller

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-07-27 21:40:51 +02:00

558 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use App\Entity\UserSystem\User;
final class ProjectControllerTest extends WebTestCase
{
private EntityManagerInterface $entityManager;
private KernelBrowser $client;
/**
* @var list<object>
*/
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<string> $exportColumns
* @param list<string> $exportLabels
*
* @return array<string, mixed>
*/
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<string, mixed>
*/
private function getColumnRequest(
string $data,
bool $searchable,
bool $orderable,
): array {
return [
'data' => $data,
'name' => '',
'searchable' => $searchable,
'orderable' => $orderable,
'search' => [
'value' => '',
'regex' => false,
],
];
}
}