export: implement streaming CSV with Excel compatibility
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>
This commit is contained in:
343
internal/services/export_test.go
Normal file
343
internal/services/export_test.go
Normal file
@@ -0,0 +1,343 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user