311 lines
8.5 KiB
Go
311 lines
8.5 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseCents(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
decSep string
|
|
want int64
|
|
wantErr bool
|
|
}{
|
|
{"positive whole", "100", ".", 10000, false},
|
|
{"positive decimal", "100.50", ".", 10050, false},
|
|
{"negative dash", "-50.25", ".", -5025, false},
|
|
{"negative parens", "(50.25)", ".", -5025, false},
|
|
{"euro symbol", "€1.234,56", ",", 123456, false},
|
|
{"euro negative parens", "(€1.234,56)", ",", -123456, false},
|
|
{"thousands separator", "1,234.56", ".", 123456, false},
|
|
{"space thousand", "1 234.56", ".", 123456, false},
|
|
{"nb space thousand", "1\u00a0234.56", ".", 123456, false},
|
|
{"zero", "0", ".", 0, false},
|
|
{"negative zero", "-0", ".", 0, false},
|
|
{"zero cents", "100.00", ".", 10000, false},
|
|
{"comma decimal", "1234,56", ",", 123456, false},
|
|
{"comma decimal with dot thousand", "1.234,56", ",", 123456, false},
|
|
{"fractional cent rounds up", "19.99", ".", 1999, false},
|
|
{"fractional cent rounds down", "10.01", ".", 1001, false},
|
|
{"euro fractional cent", "-19,99", ",", -1999, false},
|
|
{"empty string", "", ".", 0, true},
|
|
{"invalid", "abc", ".", 0, true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := parseCents(tt.input, tt.decSep)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("parseCents(%q, %q) error = %v, wantErr %v", tt.input, tt.decSep, err, tt.wantErr)
|
|
return
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("parseCents(%q, %q) = %d, want %d", tt.input, tt.decSep, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseCSV_CGD(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
file string
|
|
wantRows int
|
|
check func(*testing.T, []CSVImportRow)
|
|
}{
|
|
{
|
|
name: "basic CGD",
|
|
file: "testdata/cgd_basic.csv",
|
|
wantRows: 7,
|
|
check: func(t *testing.T, rows []CSVImportRow) {
|
|
if rows[0].Description != "Supermercado Pingo Doce" {
|
|
t.Errorf("first desc = %q", rows[0].Description)
|
|
}
|
|
if rows[0].AmountCents != -5490 {
|
|
t.Errorf("first amount = %d, want -5490 (debit)", rows[0].AmountCents)
|
|
}
|
|
if rows[0].Date != "2024-01-02" {
|
|
t.Errorf("first date = %q", rows[0].Date)
|
|
}
|
|
if rows[4].AmountCents != 250000 {
|
|
t.Errorf("salary (credit) = %d, want 250000", rows[4].AmountCents)
|
|
}
|
|
if rows[6].AmountCents != -8999 {
|
|
t.Errorf("zara (debit) = %d, want -8999", rows[6].AmountCents)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "real CGD download",
|
|
file: "testdata/comprovativo.csv",
|
|
wantRows: 36,
|
|
check: func(t *testing.T, rows []CSVImportRow) {
|
|
if rows[0].Description != "EA ELECTRONIC ARTS" {
|
|
t.Errorf("first desc = %q", rows[0].Description)
|
|
}
|
|
if rows[0].AmountCents != -2399 {
|
|
t.Errorf("ea arts (debit) = %d, want -2399", rows[0].AmountCents)
|
|
}
|
|
if rows[2].AmountCents != 200000 {
|
|
t.Errorf("trf marta (credit) = %d, want 200000", rows[2].AmountCents)
|
|
}
|
|
if rows[5].AmountCents != 200000 {
|
|
t.Errorf("second marta (credit) = %d, want 200000", rows[5].AmountCents)
|
|
}
|
|
if rows[6].AmountCents != -11998 {
|
|
t.Errorf("worten (debit) = %d, want -11998", rows[6].AmountCents)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
data, err := os.ReadFile(tt.file)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows, err := parseCSV(strings.NewReader(string(data)), CGDMapping)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != tt.wantRows {
|
|
t.Fatalf("got %d rows, want %d", len(rows), tt.wantRows)
|
|
}
|
|
if tt.check != nil {
|
|
tt.check(t, rows)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseCSV_TradeRepublic(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
file string
|
|
wantRows int
|
|
check func(*testing.T, []CSVImportRow)
|
|
}{
|
|
{
|
|
name: "TR transaction export",
|
|
file: "testdata/traderepublic_card.csv",
|
|
wantRows: 5,
|
|
check: func(t *testing.T, rows []CSVImportRow) {
|
|
if rows[0].AmountCents != 5000 {
|
|
t.Errorf("first transfer = %d, want 5000", rows[0].AmountCents)
|
|
}
|
|
if rows[4].AmountCents != 30000 {
|
|
t.Errorf("second transfer = %d, want 30000", rows[4].AmountCents)
|
|
}
|
|
if rows[0].Date != "2025-12-11" {
|
|
t.Errorf("first date = %q", rows[0].Date)
|
|
}
|
|
if rows[0].Description != "Incoming transfer from GONCALO GOMES RODRIGUES" {
|
|
t.Errorf("first desc = %q", rows[0].Description)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
data, err := os.ReadFile(tt.file)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows, err := parseCSV(strings.NewReader(string(data)), TradeRepublicMapping)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != tt.wantRows {
|
|
t.Fatalf("got %d rows, want %d", len(rows), tt.wantRows)
|
|
}
|
|
if tt.check != nil {
|
|
tt.check(t, rows)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseSecuritiesCSV_FromFile(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
file string
|
|
wantTrades int
|
|
check func(*testing.T, []securitiesTradeRow)
|
|
}{
|
|
{
|
|
name: "TR securities CSV",
|
|
file: "testdata/traderepublic_securities.csv",
|
|
wantTrades: 5,
|
|
check: func(t *testing.T, trades []securitiesTradeRow) {
|
|
if trades[0].ISIN != "IE00B3WJKG14" {
|
|
t.Errorf("first ISIN = %q", trades[0].ISIN)
|
|
}
|
|
if trades[0].Type != "buy" || trades[0].TotalCents != 3000 {
|
|
t.Errorf("first trade = %+v", trades[0])
|
|
}
|
|
if trades[2].ISIN != "IE00B5BMR087" || trades[2].TotalCents != 10000 {
|
|
t.Errorf("third trade = %+v", trades[2])
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
data, err := os.ReadFile(tt.file)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
trades, err := parseSecuritiesCSV(strings.NewReader(string(data)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(trades) != tt.wantTrades {
|
|
t.Fatalf("got %d trades, want %d", len(trades), tt.wantTrades)
|
|
}
|
|
if tt.check != nil {
|
|
tt.check(t, trades)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseCSV_EdgeCases(t *testing.T) {
|
|
t.Run("empty input", func(t *testing.T) {
|
|
_, err := parseCSV(strings.NewReader(""), CGDMapping)
|
|
if err == nil {
|
|
t.Fatal("expected error for empty input")
|
|
}
|
|
})
|
|
|
|
t.Run("all rows invalid", func(t *testing.T) {
|
|
csv := "Data;Desc;Amount\ninvalid;test;abc\n"
|
|
_, err := parseCSV(strings.NewReader(csv), CGDMapping)
|
|
if err == nil {
|
|
t.Fatal("expected error for all invalid rows")
|
|
}
|
|
})
|
|
|
|
t.Run("generic format detection", func(t *testing.T) {
|
|
csv := "date,description,amount\n2024-01-01,Test,10.50\n"
|
|
mapping := GenericMapping([]byte(csv))
|
|
if mapping.DateFormat != "2006-01-02" {
|
|
t.Errorf("date format = %q, want 2006-01-02", mapping.DateFormat)
|
|
}
|
|
rows, err := parseCSV(strings.NewReader(csv), mapping)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 1 || rows[0].AmountCents != 1050 {
|
|
t.Errorf("generic parse = %+v", rows[0])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestParseCSV_TRFullExport(t *testing.T) {
|
|
data, err := os.ReadFile("testdata/Transaction export.csv")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Run("transactions", func(t *testing.T) {
|
|
rows, err := parseCSV(strings.NewReader(string(data)), TradeRepublicMapping)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 18 {
|
|
t.Fatalf("got %d transaction rows, want 18", len(rows))
|
|
}
|
|
if rows[0].AmountCents != 5000 {
|
|
t.Errorf("first transfer = %d, want 5000", rows[0].AmountCents)
|
|
}
|
|
if rows[7].AmountCents != -10000 {
|
|
t.Errorf("row 7 = %d, want -10000 (buy -100.00)", rows[7].AmountCents)
|
|
}
|
|
})
|
|
|
|
t.Run("securities", func(t *testing.T) {
|
|
trades, err := parseSecuritiesCSV(strings.NewReader(string(data)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(trades) != 8 {
|
|
t.Fatalf("got %d trades, want 8", len(trades))
|
|
}
|
|
if trades[0].ISIN != "IE00B3WJKG14" || trades[0].Type != "buy" {
|
|
t.Errorf("first trade = %+v", trades[0])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestParseSecuritiesCSV_EdgeCases(t *testing.T) {
|
|
t.Run("missing required column", func(t *testing.T) {
|
|
csv := "Date,Name,Type,Quantity\n2024-01-01,Test,Buy,1\n"
|
|
_, err := parseSecuritiesCSV(strings.NewReader(csv))
|
|
if err == nil {
|
|
t.Fatal("expected error for missing columns")
|
|
}
|
|
})
|
|
|
|
t.Run("all zero rows skipped", func(t *testing.T) {
|
|
csv := "Date,Name,ISIN,Type,Quantity,Price,Total,Currency\n2024-01-01,,ISIN123,Buy,0,0,0,EUR\n"
|
|
_, err := parseSecuritiesCSV(strings.NewReader(csv))
|
|
if err == nil {
|
|
t.Fatal("expected error for all-zero rows")
|
|
}
|
|
})
|
|
|
|
t.Run("no data rows", func(t *testing.T) {
|
|
csv := "Date,Name,ISIN,Type,Quantity,Price,Total,Currency\n"
|
|
_, err := parseSecuritiesCSV(strings.NewReader(csv))
|
|
if err == nil {
|
|
t.Fatal("expected error for header-only CSV")
|
|
}
|
|
})
|
|
}
|