Implement Phase 1 CSV Export Optimization: - Replace buffering with true HTTP streaming (ToCSV writes to io.Writer) - Add UTF-8 BOM (0xEF 0xBB 0xBF) for correct Cyrillic display in Excel - Use semicolon (;) delimiter for Russian Excel locale - Use comma (,) as decimal separator in numbers (100,50 instead of 100.50) - Add graceful two-phase error handling: * Before streaming: return JSON errors for validation failures * During streaming: log errors only (HTTP 200 already sent) - Add backward-compatible ToCSVBytes() helper - Add GET /api/configs/:uuid/export route for configuration export New tests (13 total): - Service layer (7 tests): * UTF-8 BOM verification * Semicolon delimiter parsing * Total row formatting * Category sorting * Empty data handling * Backward compatibility wrapper * Writer error handling - Handler layer (6 tests): * Successful CSV export with streaming * Invalid request validation * Empty items validation * Config export with proper headers * 404 for missing configs * Empty config validation All tests passing, build verified. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
344 lines
7.2 KiB
Go
344 lines
7.2 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/csv"
|
|
"io"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
|
)
|
|
|
|
|
|
func TestToCSV_UTF8BOM(t *testing.T) {
|
|
svc := NewExportService(config.ExportConfig{}, nil)
|
|
|
|
data := &ExportData{
|
|
Name: "Test",
|
|
Items: []ExportItem{
|
|
{
|
|
LotName: "LOT-001",
|
|
Description: "Test Item",
|
|
Category: "CAT",
|
|
Quantity: 1,
|
|
UnitPrice: 100.0,
|
|
TotalPrice: 100.0,
|
|
},
|
|
},
|
|
Total: 100.0,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := svc.ToCSV(&buf, data); err != nil {
|
|
t.Fatalf("ToCSV failed: %v", err)
|
|
}
|
|
|
|
csvBytes := buf.Bytes()
|
|
if len(csvBytes) < 3 {
|
|
t.Fatalf("CSV too short to contain BOM")
|
|
}
|
|
|
|
// Check UTF-8 BOM: 0xEF 0xBB 0xBF
|
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
|
actualBOM := csvBytes[:3]
|
|
|
|
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
|
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
|
}
|
|
}
|
|
|
|
func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
|
svc := NewExportService(config.ExportConfig{}, nil)
|
|
|
|
data := &ExportData{
|
|
Name: "Test",
|
|
Items: []ExportItem{
|
|
{
|
|
LotName: "LOT-001",
|
|
Description: "Test Item",
|
|
Category: "CAT",
|
|
Quantity: 2,
|
|
UnitPrice: 100.50,
|
|
TotalPrice: 201.00,
|
|
},
|
|
},
|
|
Total: 201.00,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := svc.ToCSV(&buf, data); err != nil {
|
|
t.Fatalf("ToCSV failed: %v", err)
|
|
}
|
|
|
|
// Skip BOM and read CSV with semicolon delimiter
|
|
csvBytes := buf.Bytes()
|
|
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
|
reader.Comma = ';'
|
|
|
|
// Read header
|
|
header, err := reader.Read()
|
|
if err != nil {
|
|
t.Fatalf("Failed to read header: %v", err)
|
|
}
|
|
|
|
if len(header) != 6 {
|
|
t.Errorf("Expected 6 columns, got %d", len(header))
|
|
}
|
|
|
|
expectedHeader := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
|
for i, col := range expectedHeader {
|
|
if i < len(header) && header[i] != col {
|
|
t.Errorf("Column %d: expected %q, got %q", i, col, header[i])
|
|
}
|
|
}
|
|
|
|
// Read item row
|
|
itemRow, err := reader.Read()
|
|
if err != nil {
|
|
t.Fatalf("Failed to read item row: %v", err)
|
|
}
|
|
|
|
if itemRow[0] != "LOT-001" {
|
|
t.Errorf("Lot name mismatch: expected LOT-001, got %s", itemRow[0])
|
|
}
|
|
|
|
if itemRow[3] != "2" {
|
|
t.Errorf("Quantity mismatch: expected 2, got %s", itemRow[3])
|
|
}
|
|
|
|
if itemRow[4] != "100,50" {
|
|
t.Errorf("Unit price mismatch: expected 100,50, got %s", itemRow[4])
|
|
}
|
|
}
|
|
|
|
func TestToCSV_TotalRow(t *testing.T) {
|
|
svc := NewExportService(config.ExportConfig{}, nil)
|
|
|
|
data := &ExportData{
|
|
Name: "Test",
|
|
Items: []ExportItem{
|
|
{
|
|
LotName: "LOT-001",
|
|
Description: "Item 1",
|
|
Category: "CAT",
|
|
Quantity: 1,
|
|
UnitPrice: 100.0,
|
|
TotalPrice: 100.0,
|
|
},
|
|
{
|
|
LotName: "LOT-002",
|
|
Description: "Item 2",
|
|
Category: "CAT",
|
|
Quantity: 2,
|
|
UnitPrice: 50.0,
|
|
TotalPrice: 100.0,
|
|
},
|
|
},
|
|
Total: 200.0,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := svc.ToCSV(&buf, data); err != nil {
|
|
t.Fatalf("ToCSV failed: %v", err)
|
|
}
|
|
|
|
csvBytes := buf.Bytes()
|
|
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
|
reader.Comma = ';'
|
|
|
|
// Skip header and item rows
|
|
reader.Read()
|
|
reader.Read()
|
|
reader.Read()
|
|
|
|
// Read total row
|
|
totalRow, err := reader.Read()
|
|
if err != nil {
|
|
t.Fatalf("Failed to read total row: %v", err)
|
|
}
|
|
|
|
// Total row should have "ИТОГО:" in position 4 and total value in position 5
|
|
if totalRow[4] != "ИТОГО:" {
|
|
t.Errorf("Expected 'ИТОГО:' in column 4, got %q", totalRow[4])
|
|
}
|
|
|
|
if totalRow[5] != "200,00" {
|
|
t.Errorf("Expected total 200,00, got %s", totalRow[5])
|
|
}
|
|
}
|
|
|
|
func TestToCSV_CategorySorting(t *testing.T) {
|
|
// Test category sorting without category repo (items maintain original order)
|
|
svc := NewExportService(config.ExportConfig{}, nil)
|
|
|
|
data := &ExportData{
|
|
Name: "Test",
|
|
Items: []ExportItem{
|
|
{
|
|
LotName: "LOT-001",
|
|
Category: "CAT-A",
|
|
Quantity: 1,
|
|
UnitPrice: 100.0,
|
|
TotalPrice: 100.0,
|
|
},
|
|
{
|
|
LotName: "LOT-002",
|
|
Category: "CAT-C",
|
|
Quantity: 1,
|
|
UnitPrice: 100.0,
|
|
TotalPrice: 100.0,
|
|
},
|
|
{
|
|
LotName: "LOT-003",
|
|
Category: "CAT-B",
|
|
Quantity: 1,
|
|
UnitPrice: 100.0,
|
|
TotalPrice: 100.0,
|
|
},
|
|
},
|
|
Total: 300.0,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := svc.ToCSV(&buf, data); err != nil {
|
|
t.Fatalf("ToCSV failed: %v", err)
|
|
}
|
|
|
|
csvBytes := buf.Bytes()
|
|
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
|
reader.Comma = ';'
|
|
|
|
// Skip header
|
|
reader.Read()
|
|
|
|
// Without category repo, items maintain original order
|
|
row1, _ := reader.Read()
|
|
if row1[0] != "LOT-001" {
|
|
t.Errorf("Expected LOT-001 first, got %s", row1[0])
|
|
}
|
|
|
|
row2, _ := reader.Read()
|
|
if row2[0] != "LOT-002" {
|
|
t.Errorf("Expected LOT-002 second, got %s", row2[0])
|
|
}
|
|
|
|
row3, _ := reader.Read()
|
|
if row3[0] != "LOT-003" {
|
|
t.Errorf("Expected LOT-003 third, got %s", row3[0])
|
|
}
|
|
}
|
|
|
|
func TestToCSV_EmptyData(t *testing.T) {
|
|
svc := NewExportService(config.ExportConfig{}, nil)
|
|
|
|
data := &ExportData{
|
|
Name: "Test",
|
|
Items: []ExportItem{},
|
|
Total: 0.0,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := svc.ToCSV(&buf, data); err != nil {
|
|
t.Fatalf("ToCSV failed: %v", err)
|
|
}
|
|
|
|
csvBytes := buf.Bytes()
|
|
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
|
reader.Comma = ';'
|
|
|
|
// Should have header and total row
|
|
header, err := reader.Read()
|
|
if err != nil {
|
|
t.Fatalf("Failed to read header: %v", err)
|
|
}
|
|
|
|
if len(header) != 6 {
|
|
t.Errorf("Expected 6 columns, got %d", len(header))
|
|
}
|
|
|
|
totalRow, err := reader.Read()
|
|
if err != nil {
|
|
t.Fatalf("Failed to read total row: %v", err)
|
|
}
|
|
|
|
if totalRow[4] != "ИТОГО:" {
|
|
t.Errorf("Expected ИТОГО: in total row, got %s", totalRow[4])
|
|
}
|
|
}
|
|
|
|
func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
|
svc := NewExportService(config.ExportConfig{}, nil)
|
|
|
|
data := &ExportData{
|
|
Name: "Test",
|
|
Items: []ExportItem{
|
|
{
|
|
LotName: "LOT-001",
|
|
Description: "Test Item",
|
|
Category: "CAT",
|
|
Quantity: 1,
|
|
UnitPrice: 100.0,
|
|
TotalPrice: 100.0,
|
|
},
|
|
},
|
|
Total: 100.0,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
csvBytes, err := svc.ToCSVBytes(data)
|
|
if err != nil {
|
|
t.Fatalf("ToCSVBytes failed: %v", err)
|
|
}
|
|
|
|
if len(csvBytes) < 3 {
|
|
t.Fatalf("CSV bytes too short")
|
|
}
|
|
|
|
// Verify BOM is present
|
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
|
actualBOM := csvBytes[:3]
|
|
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
|
t.Errorf("UTF-8 BOM mismatch in ToCSVBytes")
|
|
}
|
|
}
|
|
|
|
func TestToCSV_WriterError(t *testing.T) {
|
|
svc := NewExportService(config.ExportConfig{}, nil)
|
|
|
|
data := &ExportData{
|
|
Name: "Test",
|
|
Items: []ExportItem{
|
|
{
|
|
LotName: "LOT-001",
|
|
Description: "Test",
|
|
Category: "CAT",
|
|
Quantity: 1,
|
|
UnitPrice: 100.0,
|
|
TotalPrice: 100.0,
|
|
},
|
|
},
|
|
Total: 100.0,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
// Use a failing writer
|
|
failingWriter := &failingWriter{}
|
|
|
|
if err := svc.ToCSV(failingWriter, data); err == nil {
|
|
t.Errorf("Expected error from failing writer, got nil")
|
|
}
|
|
}
|
|
|
|
// failingWriter always returns an error
|
|
type failingWriter struct{}
|
|
|
|
func (fw *failingWriter) Write(p []byte) (int, error) {
|
|
return 0, io.EOF
|
|
}
|