resetDatabaseOnce();
require_once __DIR__ . '/../../app/Helpers/export_helper.php';
parent::setUp();
}
public function testSanitizeExportCsvValue_PrefixesEqualsWithQuote(): void
{
$this->assertSame("'=SUM(A1:A2)", sanitizeExportCsvValue('=SUM(A1:A2)'));
}
public function testSanitizeExportCsvValue_PrefixesPlusWithQuote(): void
{
$this->assertSame("'+HYPERLINK(1)", sanitizeExportCsvValue('+HYPERLINK(1)'));
}
public function testSanitizeExportCsvValue_PrefixesMinusWithQuote(): void
{
$this->assertSame("'-cmd", sanitizeExportCsvValue('-cmd'));
}
public function testSanitizeExportCsvValue_PrefixesAtWithQuote(): void
{
$this->assertSame("'@echo", sanitizeExportCsvValue('@echo'));
}
public function testSanitizeExportCsvValue_AcceptsNumericStringUnchanged(): void
{
$this->assertSame('-5', sanitizeExportCsvValue('-5'));
}
public function testSanitizeExportCsvValue_AcceptsPlainTextUnchanged(): void
{
$this->assertSame('Regular Item Name', sanitizeExportCsvValue('Regular Item Name'));
}
public function testSanitizeExportCsvValue_AcceptsEmptyStringUnchanged(): void
{
$this->assertSame('', sanitizeExportCsvValue(''));
}
public function testSanitizeExportCsvValue_AcceptsNonStringUnchanged(): void
{
$this->assertSame(42, sanitizeExportCsvValue(42));
$this->assertNull(sanitizeExportCsvValue(null));
}
public function testWriteExportCsv_IncludesBomAndHeaderRow(): void
{
$headers = [['name' => 'Name'], ['value' => 'Value']];
$rows = [(object) ['name' => 'Widget', 'value' => '10']];
$csv = writeExportCsv($headers, $rows, ',');
$this->assertStringStartsWith("\xEF\xBB\xBF", $csv);
$this->assertStringContainsString("Name,Value", $csv);
$this->assertStringContainsString("Widget,10", $csv);
}
public function testWriteExportCsv_SanitizesFormulaValues(): void
{
$headers = [['name' => 'Name']];
$rows = [(object) ['name' => '=cmd|calc']];
$csv = writeExportCsv($headers, $rows, ',');
$this->assertStringContainsString("'=cmd|calc", $csv);
}
public function testWriteExportCsv_UsesTabDelimiterForTxtFormat(): void
{
$headers = [['a' => 'A'], ['b' => 'B']];
$rows = [(object) ['a' => '1', 'b' => '2']];
$csv = writeExportCsv($headers, $rows, "\t");
$this->assertStringContainsString("A\tB", $csv);
$this->assertStringContainsString("1\t2", $csv);
}
public function testWriteExportJson_RoundTripsValues(): void
{
$headers = [['name' => 'Name'], ['value' => 'Value']];
$rows = [(object) ['name' => 'Widget', 'value' => '10']];
$json = writeExportJson($headers, $rows);
$decoded = json_decode($json, true);
$this->assertSame([['name' => 'Widget', 'value' => '10']], $decoded);
}
public function testWriteExportJson_AcceptsEmptyRowsAsEmptyArray(): void
{
$json = writeExportJson([['name' => 'Name']], []);
$this->assertSame([], json_decode($json, true));
}
public function testSanitizeExportXmlTag_PrefixesNumericLeadingKey(): void
{
$this->assertSame('col_123', sanitizeExportXmlTag('123'));
}
public function testSanitizeExportXmlTag_ReplacesNonAlnumChars(): void
{
$this->assertSame('a_b_c', sanitizeExportXmlTag('a.b-c'));
}
public function testSanitizeExportXmlTag_FallsBackToColumnWhenEmpty(): void
{
$this->assertSame('column', sanitizeExportXmlTag(''));
}
public function testSanitizeExportXmlTag_AcceptsValidKeyUnchanged(): void
{
$this->assertSame('item_id', sanitizeExportXmlTag('item_id'));
}
public function testWriteExportXml_ProducesValidDocumentWithSanitizedTags(): void
{
$headers = [['123' => 'Numeric'], ['items.item_id' => 'Id']];
$rows = [(object) ['123' => 'foo', 'item_id' => '7']];
$xml = writeExportXml($headers, $rows);
$this->assertStringContainsString('foo', $xml);
$this->assertStringContainsString('7', $xml);
$sxml = simplexml_load_string($xml);
$this->assertNotFalse($sxml, 'writeExportXml() must produce well-formed XML');
}
public function testGenerateExportFile_RejectsUnsupportedFormat(): void
{
$this->expectException(InvalidArgumentException::class);
generateExportFile('exe', [['a' => 'A']], [], 'base', 'items');
}
public function testGenerateExportFile_UsesCorrectExtensionPerFormat(): void
{
$headers = [['name' => 'Name']];
$rows = [(object) ['name' => 'Widget']];
$csvFile = generateExportFile('csv', $headers, $rows, 'export', 'items');
$this->assertSame('export.csv', $csvFile['filename']);
$jsonFile = generateExportFile('json', $headers, $rows, 'export', 'items');
$this->assertSame('export.json', $jsonFile['filename']);
$txtFile = generateExportFile('txt', $headers, $rows, 'export', 'items');
$this->assertSame('export.txt', $txtFile['filename']);
$xmlFile = generateExportFile('xml', $headers, $rows, 'export', 'items');
$this->assertSame('export.xml', $xmlFile['filename']);
}
public function testExportSqlUnprefixedTableName_StripsConfiguredPrefix(): void
{
$db = db_connect();
$this->assertSame('items', exportSqlUnprefixedTableName($db->prefixTable('items')));
}
public function testExportSqlUnprefixedTableName_AcceptsUnprefixedNameUnchanged(): void
{
$this->assertSame('items', exportSqlUnprefixedTableName('items'));
}
public function testWriteExportSql_ReturnsCommentForNonRestorableTable(): void
{
$sql = writeExportSql([['foo' => 'Foo']], [], 'ospos_some_report_view');
$this->assertStringContainsString('no directly-restorable columns', $sql);
}
public function testWriteExportSql_ReturnsCommentWhenNoHeaderColumnsMapToTable(): void
{
$db = db_connect();
$sql = writeExportSql([['not_a_real_column' => 'Nope']], [], $db->prefixTable('giftcards'));
$this->assertStringContainsString("None of this export's columns map to real", $sql);
}
public function testWriteExportSql_ReturnsCommentForEmptyRowSet(): void
{
$db = db_connect();
$sql = writeExportSql([['giftcard_id' => 'Id']], [], $db->prefixTable('giftcards'));
$this->assertStringContainsString('No rows matched', $sql);
}
public function testWriteExportSql_ProducesInsertWithRequiredHiddenColumns(): void
{
$db = db_connect();
$tableName = $db->prefixTable('giftcards');
$headers = [
['giftcard_id' => 'Id'],
['giftcard_number' => 'Number'],
['value' => 'Value'],
];
$rows = [(object) ['giftcard_id' => 1, 'giftcard_number' => 'GC-1', 'value' => '25.00']];
$sql = writeExportSql($headers, $rows, $tableName);
$this->assertStringContainsString("INSERT INTO `$tableName`", $sql);
$this->assertStringContainsString('`giftcard_id`, `giftcard_number`, `value`', $sql);
$this->assertStringContainsString("'GC-1'", $sql);
}
public function testWriteExportSql_IncludesHiddenNotNullColumnsForItems(): void
{
$db = db_connect();
$tableName = $db->prefixTable('items');
$headers = [
['items.item_id' => 'Id'],
['name' => 'Name'],
];
$rows = [(object) [
'items.item_id' => 1,
'name' => 'Widget',
'description' => 'A widget',
'allow_alt_description' => 0,
'is_serialized' => 0,
]];
$sql = writeExportSql($headers, $rows, $tableName);
// Hidden NOT NULL columns must be appended even though they aren't in $headers.
$this->assertStringContainsString('`description`', $sql);
$this->assertStringContainsString('`allow_alt_description`', $sql);
$this->assertStringContainsString('`is_serialized`', $sql);
$this->assertStringContainsString("'A widget'", $sql);
}
public function testSweepStaleExportCache_RemovesOnlyFilesOlderThanTenMinutes(): void
{
$directory = WRITEPATH . 'cache/exports/';
if (!is_dir($directory)) {
mkdir($directory, 0755, true);
}
$staleFile = $directory . 'xlsx_stale_' . uniqid() . '.tmp';
$freshFile = $directory . 'xlsx_fresh_' . uniqid() . '.tmp';
file_put_contents($staleFile, 'stale');
file_put_contents($freshFile, 'fresh');
touch($staleFile, time() - 700);
touch($freshFile, time() - 5);
try {
sweepStaleExportCache();
$this->assertFileDoesNotExist($staleFile);
$this->assertFileExists($freshFile);
} finally {
@unlink($staleFile);
@unlink($freshFile);
}
}
public function testExportXlsxColumnFormats_AssignsIntegerFormatForIdColumns(): void
{
$this->injectOsposConfig();
$headers = [['items.item_id' => 'Id']];
$formats = exportXlsxColumnFormats($headers);
$this->assertSame('0', $formats[0]);
}
public function testExportXlsxColumnFormats_AssignsQuantityFormat(): void
{
$this->injectOsposConfig();
$headers = [['quantity' => 'Quantity']];
$formats = exportXlsxColumnFormats($headers);
$this->assertSame('0.0000', $formats[0]);
}
public function testExportXlsxColumnFormats_AssignsCurrencyFormat(): void
{
$this->injectOsposConfig();
$headers = [['cost_price' => 'Cost Price']];
$formats = exportXlsxColumnFormats($headers);
$this->assertSame('0.00', $formats[0]);
}
public function testExportXlsxColumnFormats_AssignsDecimalFormatForDecimalTypeHeader(): void
{
$this->injectOsposConfig();
$headers = [['attribute_weight' => 'Weight', 'type' => DECIMAL]];
$formats = exportXlsxColumnFormats($headers);
$this->assertSame('0.00', $formats[0]);
}
public function testExportXlsxColumnFormats_AcceptsPlainTextColumnAsNull(): void
{
$this->injectOsposConfig();
$headers = [['name' => 'Name']];
$formats = exportXlsxColumnFormats($headers);
$this->assertNull($formats[0]);
}
private function injectOsposConfig(): void
{
model(Appconfig::class)->batch_save([
'quantity_decimals' => 4,
'currency_decimals' => 2,
'number_locale' => 'en_US',
'currency_symbol' => '$',
]);
}
private function newPdfWriter(): ExportPdfWriter
{
if (!defined('FPDF_FONTPATH')) {
define('FPDF_FONTPATH', FCPATH . 'fonts/');
}
require_once APPPATH . '../vendor/setasign/tfpdf/tfpdf.php';
$pdf = new ExportPdfWriter('L', 'mm', 'A4');
$pdf->AddFont('DejaVuSans', '', 'DejaVuSans.ttf', true);
$pdf->AddFont('DejaVuSans', 'B', 'DejaVuSansB.ttf', true);
$pdf->SetFont('DejaVuSans', '', 9);
return $pdf;
}
public function testExportPdfDisplayLabel_StripsAttributePrefix(): void
{
$this->assertSame('Color', exportPdfDisplayLabel('attribute_Color', 'attribute_Color'));
}
public function testExportPdfDisplayLabel_AcceptsNonAttributeKeyUnchanged(): void
{
$this->assertSame('Item Name', exportPdfDisplayLabel('name', 'Item Name'));
}
public function testExportPdfColumnWidths_GivesIdColumnFixedBarcodeWidth(): void
{
$pdf = $this->newPdfWriter();
$headers = [['items.item_id' => 'Id']];
$widths = exportPdfColumnWidths($pdf, $headers, 200.0);
$this->assertGreaterThan(0, $widths[0]);
}
public function testExportPdfColumnWidths_ExpandColumnAbsorbsLeftoverWidth(): void
{
$pdf = $this->newPdfWriter();
$headers = [['category' => 'Category'], ['name' => 'Name']];
$widths = exportPdfColumnWidths($pdf, $headers, 200.0);
$this->assertGreaterThan($widths[0], $widths[1]);
}
public function testExportPdfColumnWidths_SplitsRemainingWidthAcrossMultipleExpandColumns(): void
{
$pdf = $this->newPdfWriter();
$headers = [
['attribute_notes' => 'Notes', 'type' => TEXT],
['attribute_description' => 'Description', 'type' => TEXT],
];
$widths = exportPdfColumnWidths($pdf, $headers, 200.0);
$this->assertEqualsWithDelta($widths[0], $widths[1], 0.01);
}
public function testExportPdfTruncateCellText_AcceptsShortTextUnchanged(): void
{
$pdf = $this->newPdfWriter();
$this->assertSame('Short', exportPdfTruncateCellText($pdf, 'Short', 50.0));
}
public function testExportPdfTruncateCellText_TruncatesLongTextWithEllipsis(): void
{
$pdf = $this->newPdfWriter();
$longText = str_repeat('Lorem ipsum dolor sit amet ', 10);
$result = exportPdfTruncateCellText($pdf, $longText, 20.0);
$this->assertStringEndsWith('…', $result);
$this->assertLessThan(mb_strlen($longText), mb_strlen($result));
$this->assertLessThanOrEqual(20.0 - 2.0, $pdf->GetStringWidth($result));
}
public function testExportPdfTruncateCellText_ReturnsEllipsisAloneWhenColumnTooNarrow(): void
{
$pdf = $this->newPdfWriter();
$result = exportPdfTruncateCellText($pdf, 'Something long', 2.0);
$this->assertSame('…', $result);
}
public function testExportPdfColumnAlignments_CentersConfiguredColumns(): void
{
$headers = [['cost_price' => 'Cost Price'], ['name' => 'Name']];
$alignments = exportPdfColumnAlignments($headers);
$this->assertSame('C', $alignments[0]);
$this->assertSame('L', $alignments[1]);
}
public function testExportPdfFormatRowValues_FormatsQuantityAndCurrencyColumns(): void
{
$this->injectOsposConfig();
$values = exportPdfFormatRowValues(['quantity' => '10', 'cost_price' => '5.5', 'name' => 'Widget']);
$this->assertSame('Widget', $values['name']);
$this->assertSame(to_quantity_decimals('10'), $values['quantity']);
$this->assertSame(to_currency('5.5'), $values['cost_price']);
$this->assertNotSame('10', $values['quantity']);
$this->assertNotSame('5.5', $values['cost_price']);
}
public function testExportPdfFormatRowValues_AcceptsEmptyQuantityUnchanged(): void
{
$values = exportPdfFormatRowValues(['quantity' => '', 'cost_price' => '']);
$this->assertSame('', $values['quantity']);
$this->assertSame('', $values['cost_price']);
}
public function testExportPdfSplitHeaders_SeparatesPrimaryAndSecondaryColumns(): void
{
$headers = [
['items.item_id' => 'Id'],
['name' => 'Name'],
['attribute_notes' => 'Notes'],
];
[$primary, $secondary] = exportPdfSplitHeaders($headers);
$this->assertCount(2, $primary);
$this->assertCount(2, $secondary, 'secondary group must include the repeated id column plus the attribute column');
$this->assertSame('items.item_id', array_key_first($secondary[0]));
$this->assertSame('attribute_notes', array_key_first($secondary[1]));
}
public function testExportPdfSplitHeaders_AcceptsAllPrimaryColumnsWithEmptySecondary(): void
{
$headers = [['items.item_id' => 'Id'], ['name' => 'Name']];
[$primary, $secondary] = exportPdfSplitHeaders($headers);
$this->assertCount(2, $primary);
$this->assertSame([], $secondary);
}
public function testWriteExportPdf_ProducesNonEmptyPdfDocument(): void
{
$headers = [['items.item_id' => 'Id'], ['name' => 'Name']];
$rows = [(object) ['items.item_id' => 1, 'name' => 'Widget']];
$pdf = writeExportPdf($headers, $rows);
$this->assertStringStartsWith('%PDF-', $pdf);
}
}