package services import ( "bytes" "encoding/csv" "io" "testing" "time" "git.mchus.pro/mchus/quoteforge/internal/config" ) func newTestProjectData(items []ExportItem, article string, serverCount int) *ProjectExportData { var unitTotal float64 for _, item := range items { unitTotal += item.UnitPrice * float64(item.Quantity) } if serverCount < 1 { serverCount = 1 } return &ProjectExportData{ Configs: []ConfigExportBlock{ { Article: article, ServerCount: serverCount, UnitPrice: unitTotal, Items: items, }, }, CreatedAt: time.Now(), } } func TestToCSV_UTF8BOM(t *testing.T) { svc := NewExportService(config.ExportConfig{}, nil, nil) data := newTestProjectData([]ExportItem{ { LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0, }, }, "TEST-ARTICLE", 1) 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") } expectedBOM := []byte{0xEF, 0xBB, 0xBF} actualBOM := csvBytes[:3] if !bytes.Equal(actualBOM, expectedBOM) { t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM) } } func TestToCSV_SemicolonDelimiter(t *testing.T) { svc := NewExportService(config.ExportConfig{}, nil, nil) data := newTestProjectData([]ExportItem{ { LotName: "LOT-001", Category: "CAT", Quantity: 2, UnitPrice: 100.50, TotalPrice: 201.00, }, }, "TEST-ARTICLE", 1) 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 = ';' // Read header header, err := reader.Read() if err != nil { t.Fatalf("Failed to read header: %v", err) } if len(header) != 8 { t.Errorf("Expected 8 columns, got %d", len(header)) } expectedHeader := []string{"Line", "Type", "p/n", "Description", "Qty (1 pcs.)", "Qty (total)", "Price (1 pcs.)", "Price (total)"} 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 server row serverRow, err := reader.Read() if err != nil { t.Fatalf("Failed to read server row: %v", err) } if serverRow[0] != "10" { t.Errorf("Expected line number 10, got %s", serverRow[0]) } if serverRow[2] != "TEST-ARTICLE" { t.Errorf("Expected article TEST-ARTICLE, got %s", serverRow[2]) } // Read component row itemRow, err := reader.Read() if err != nil { t.Fatalf("Failed to read item row: %v", err) } if itemRow[2] != "LOT-001" { t.Errorf("Lot name mismatch: expected LOT-001, got %s", itemRow[2]) } if itemRow[4] != "2" { t.Errorf("Quantity mismatch: expected 2, got %s", itemRow[4]) } if itemRow[6] != "100,5" { t.Errorf("Unit price mismatch: expected 100,5, got %s", itemRow[6]) } } func TestToCSV_ServerRow(t *testing.T) { svc := NewExportService(config.ExportConfig{}, nil, nil) data := newTestProjectData([]ExportItem{ {LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0}, {LotName: "LOT-002", Category: "CAT", Quantity: 2, UnitPrice: 50.0, TotalPrice: 100.0}, }, "DL380-ART", 10) 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() // Read server row serverRow, err := reader.Read() if err != nil { t.Fatalf("Failed to read server row: %v", err) } if serverRow[0] != "10" { t.Errorf("Expected line 10, got %s", serverRow[0]) } if serverRow[2] != "DL380-ART" { t.Errorf("Expected article DL380-ART, got %s", serverRow[2]) } if serverRow[5] != "10" { t.Errorf("Expected server count 10, got %s", serverRow[5]) } // UnitPrice = 100 + 100 = 200 if serverRow[6] != "200" { t.Errorf("Expected unit price 200, got %s", serverRow[6]) } // TotalPrice = 200 * 10 = 2000 if serverRow[7] != "2 000" { t.Errorf("Expected total price '2 000', got %q", serverRow[7]) } } func TestToCSV_CategorySorting(t *testing.T) { svc := NewExportService(config.ExportConfig{}, nil, nil) data := newTestProjectData([]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}, }, "ART", 1) 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 server row reader.Read() reader.Read() // Without category repo, items maintain original order row1, _ := reader.Read() if row1[2] != "LOT-001" { t.Errorf("Expected LOT-001 first, got %s", row1[2]) } row2, _ := reader.Read() if row2[2] != "LOT-002" { t.Errorf("Expected LOT-002 second, got %s", row2[2]) } row3, _ := reader.Read() if row3[2] != "LOT-003" { t.Errorf("Expected LOT-003 third, got %s", row3[2]) } } func TestToCSV_EmptyData(t *testing.T) { svc := NewExportService(config.ExportConfig{}, nil, nil) data := &ProjectExportData{ Configs: []ConfigExportBlock{}, 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 = ';' header, err := reader.Read() if err != nil { t.Fatalf("Failed to read header: %v", err) } if len(header) != 8 { t.Errorf("Expected 8 columns, got %d", len(header)) } // No more rows expected _, err = reader.Read() if err != io.EOF { t.Errorf("Expected EOF after header, got: %v", err) } } func TestToCSVBytes_BackwardCompat(t *testing.T) { svc := NewExportService(config.ExportConfig{}, nil, nil) data := newTestProjectData([]ExportItem{ {LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0}, }, "ART", 1) csvBytes, err := svc.ToCSVBytes(data) if err != nil { t.Fatalf("ToCSVBytes failed: %v", err) } if len(csvBytes) < 3 { t.Fatalf("CSV bytes too short") } expectedBOM := []byte{0xEF, 0xBB, 0xBF} actualBOM := csvBytes[:3] if !bytes.Equal(actualBOM, expectedBOM) { t.Errorf("UTF-8 BOM mismatch in ToCSVBytes") } } func TestToCSV_WriterError(t *testing.T) { svc := NewExportService(config.ExportConfig{}, nil, nil) data := newTestProjectData([]ExportItem{ {LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0}, }, "ART", 1) failingWriter := &failingWriter{} if err := svc.ToCSV(failingWriter, data); err == nil { t.Errorf("Expected error from failing writer, got nil") } } func TestToCSV_MultipleBlocks(t *testing.T) { svc := NewExportService(config.ExportConfig{}, nil, nil) data := &ProjectExportData{ Configs: []ConfigExportBlock{ { Article: "ART-1", ServerCount: 2, UnitPrice: 500.0, Items: []ExportItem{ {LotName: "LOT-A", Category: "CPU", Quantity: 1, UnitPrice: 500.0, TotalPrice: 500.0}, }, }, { Article: "ART-2", ServerCount: 3, UnitPrice: 1000.0, Items: []ExportItem{ {LotName: "LOT-B", Category: "MEM", Quantity: 2, UnitPrice: 500.0, TotalPrice: 1000.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 = ';' reader.FieldsPerRecord = -1 // allow variable fields // Header reader.Read() // Block 1: server row srv1, _ := reader.Read() if srv1[0] != "10" { t.Errorf("Block 1 line: expected 10, got %s", srv1[0]) } if srv1[7] != "1 000" { t.Errorf("Block 1 total: expected '1 000', got %q", srv1[7]) } // Block 1: component row comp1, _ := reader.Read() if comp1[2] != "LOT-A" { t.Errorf("Block 1 component: expected LOT-A, got %s", comp1[2]) } // Separator row sep, _ := reader.Read() allEmpty := true for _, v := range sep { if v != "" { allEmpty = false } } if !allEmpty { t.Errorf("Expected empty separator row, got %v", sep) } // Block 2: server row srv2, _ := reader.Read() if srv2[0] != "20" { t.Errorf("Block 2 line: expected 20, got %s", srv2[0]) } if srv2[7] != "3 000" { t.Errorf("Block 2 total: expected '3 000', got %q", srv2[7]) } } func TestFormatPriceWithSpace(t *testing.T) { tests := []struct { input float64 expected string }{ {0, "0"}, {100, "100"}, {1000, "1 000"}, {10470, "10 470"}, {104700, "104 700"}, {1000000, "1 000 000"}, } for _, tt := range tests { result := formatPriceWithSpace(tt.input) if result != tt.expected { t.Errorf("formatPriceWithSpace(%v): expected %q, got %q", tt.input, tt.expected, result) } } } func TestFormatPriceComma(t *testing.T) { tests := []struct { input float64 expected string }{ {100.0, "100"}, {2074.5, "2074,5"}, {100.50, "100,5"}, {99.99, "99,99"}, {0, "0"}, } for _, tt := range tests { result := formatPriceComma(tt.input) if result != tt.expected { t.Errorf("formatPriceComma(%v): expected %q, got %q", tt.input, tt.expected, result) } } } // failingWriter always returns an error type failingWriter struct{} func (fw *failingWriter) Write(p []byte) (int, error) { return 0, io.EOF }