Files
QuoteForge/internal/services/export_test.go
Mikhail Chusavitin bca82f9dc0 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>
2026-02-09 10:47:10 +03:00

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
}