package csvimport import ( "strings" "testing" ) func TestParseHappyPath(t *testing.T) { in := `name,email,phone,plus_ones Alex Doe,alex@example.com,+447700900123,1 Sam Patel,SAM@example.com,,0 Jordan Lee,,+1 (555) 123-4567,2 ` r, err := Parse(strings.NewReader(in), Options{}) if err != nil { t.Fatalf("parse: %v", err) } if got, want := len(r.Rows), 3; got != want { t.Fatalf("rows: got %d want %d (errors=%+v)", got, want, r.Errors) } if r.Rows[1].Email != "sam@example.com" { t.Errorf("email not lowercased: %q", r.Rows[1].Email) } if r.Rows[2].Phone != "+15551234567" { t.Errorf("phone not stripped: %q", r.Rows[2].Phone) } if r.Rows[0].Phone != "+447700900123" { t.Errorf("phone should keep leading +: %q", r.Rows[0].Phone) } } func TestParsePhoneNormalisedToPlus(t *testing.T) { // E.164 without explicit "+" is accepted and normalised to include one. in := "name,phone\nAlex,447700900123\n" r, err := Parse(strings.NewReader(in), Options{}) if err != nil { t.Fatalf("parse: %v", err) } if len(r.Rows) != 1 || r.Rows[0].Phone != "+447700900123" { t.Fatalf("expected normalised phone, got: rows=%+v errors=%+v", r.Rows, r.Errors) } } func TestParsePhoneRejectsLocalFormat(t *testing.T) { // Local UK / GH style numbers (leading 0, no country code) must be // rejected — they break SMS routing and WhatsApp click-to-chat. in := `name,phone UK Local,07700900123 GH Local,0244123456 With Plus And Zero,+0244123456 ` r, err := Parse(strings.NewReader(in), Options{}) if err != nil { t.Fatalf("parse: %v", err) } if len(r.Errors) != 3 { t.Fatalf("expected 3 errors for local-format phones, got %d: %+v", len(r.Errors), r.Errors) } } func TestParseHeaderVariants(t *testing.T) { cases := []string{ "Name,Email,Phone,Plus Ones\nMira,m@x.com,,1\n", "Guest Name,E-Mail,Telephone,+1\nMira,m@x.com,,1\n", "full_name,email_address,mobile,plusones\nMira,m@x.com,,1\n", } for i, in := range cases { t.Run(string(rune('a'+i)), func(t *testing.T) { r, err := Parse(strings.NewReader(in), Options{}) if err != nil { t.Fatalf("parse: %v", err) } if len(r.Rows) != 1 { t.Fatalf("rows=%d errors=%+v", len(r.Rows), r.Errors) } if r.Rows[0].PlusOnes != 1 { t.Errorf("plusones not detected: %+v", r.Rows[0]) } }) } } func TestParseRowValidation(t *testing.T) { in := `name,email,phone,plus_ones ,a@x.com,,1 Valid Guest,not-an-email,,0 Phone Person,,abc,0 Negative,,,-1 Email Only,e@x.com,, ` r, err := Parse(strings.NewReader(in), Options{}) if err != nil { t.Fatalf("parse: %v", err) } if len(r.Errors) != 4 { t.Fatalf("expected 4 errors, got %d: %+v", len(r.Errors), r.Errors) } // "Email Only" row is valid: name present, email parses, phone+plus blank. if len(r.Rows) != 1 || r.Rows[0].Name != "Email Only" { t.Fatalf("expected 1 valid row, got %+v", r.Rows) } } func TestParseUTF8BOM(t *testing.T) { in := "\xEF\xBB\xBFname,email\nMira,m@x.com\n" r, err := Parse(strings.NewReader(in), Options{}) if err != nil { t.Fatalf("parse: %v", err) } if len(r.Rows) != 1 || r.Rows[0].Name != "Mira" { t.Fatalf("BOM not stripped: %+v", r.Rows) } } func TestParseUTF16LE(t *testing.T) { // "name,email\nMira,m@x.com\n" in UTF-16 LE with BOM. in := []byte{0xFF, 0xFE} for _, r := range "name,email\nMira,m@x.com\n" { in = append(in, byte(r), byte(r>>8)) } r, err := Parse(strings.NewReader(string(in)), Options{}) if err != nil { t.Fatalf("parse: %v", err) } if len(r.Rows) != 1 || r.Rows[0].Name != "Mira" { t.Fatalf("UTF-16 LE not decoded: %+v", r.Rows) } } func TestParseEmptyTrailingRows(t *testing.T) { in := "name,email\nMira,m@x.com\n,,\n\n,\n" r, err := Parse(strings.NewReader(in), Options{}) if err != nil { t.Fatalf("parse: %v", err) } if r.TotalCount != 1 { t.Fatalf("trailing blanks counted: TotalCount=%d", r.TotalCount) } } func TestParseMissingNameHeader(t *testing.T) { in := "email,phone\na@x.com,\n" if _, err := Parse(strings.NewReader(in), Options{}); err == nil { t.Fatal("expected error for missing name column") } } func TestParseMaxRows(t *testing.T) { var b strings.Builder b.WriteString("name\n") for i := 0; i < 11; i++ { b.WriteString("X\n") } if _, err := Parse(strings.NewReader(b.String()), Options{MaxRows: 10}); err == nil { t.Fatal("expected error when exceeding MaxRows") } }