453 lines
11 KiB
Go
453 lines
11 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/csv"
|
|
"io"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
)
|
|
|
|
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 TestProjectToExportData_SortsByLine(t *testing.T) {
|
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
|
|
|
configs := []models.Configuration{
|
|
{
|
|
UUID: "cfg-1",
|
|
Line: 30,
|
|
Article: "ART-30",
|
|
ServerCount: 1,
|
|
Items: models.ConfigItems{{LotName: "LOT-30", Quantity: 1, UnitPrice: 300}},
|
|
CreatedAt: time.Now().Add(-1 * time.Hour),
|
|
},
|
|
{
|
|
UUID: "cfg-2",
|
|
Line: 10,
|
|
Article: "ART-10",
|
|
ServerCount: 1,
|
|
Items: models.ConfigItems{{LotName: "LOT-10", Quantity: 1, UnitPrice: 100}},
|
|
CreatedAt: time.Now().Add(-2 * time.Hour),
|
|
},
|
|
{
|
|
UUID: "cfg-3",
|
|
Line: 20,
|
|
Article: "ART-20",
|
|
ServerCount: 1,
|
|
Items: models.ConfigItems{{LotName: "LOT-20", Quantity: 1, UnitPrice: 200}},
|
|
CreatedAt: time.Now().Add(-3 * time.Hour),
|
|
},
|
|
}
|
|
|
|
data := svc.ProjectToExportData(configs)
|
|
if len(data.Configs) != 3 {
|
|
t.Fatalf("expected 3 blocks, got %d", len(data.Configs))
|
|
}
|
|
if data.Configs[0].Article != "ART-10" || data.Configs[0].Line != 10 {
|
|
t.Fatalf("first block must be line 10, got article=%s line=%d", data.Configs[0].Article, data.Configs[0].Line)
|
|
}
|
|
if data.Configs[1].Article != "ART-20" || data.Configs[1].Line != 20 {
|
|
t.Fatalf("second block must be line 20, got article=%s line=%d", data.Configs[1].Article, data.Configs[1].Line)
|
|
}
|
|
if data.Configs[2].Article != "ART-30" || data.Configs[2].Line != 30 {
|
|
t.Fatalf("third block must be line 30, got article=%s line=%d", data.Configs[2].Article, data.Configs[2].Line)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|