// Vikunja is a to-do list application to facilitate your life. // Copyright 2018-present Vikunja and contributors. All rights reserved. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package csv import ( "bytes" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestStripBOM(t *testing.T) { tests := []struct { name string input []byte expected []byte }{ { name: "with BOM", input: []byte{0xEF, 0xBB, 0xBF, 'H', 'e', 'l', 'l', 'o'}, expected: []byte("Hello"), }, { name: "without BOM", input: []byte("Hello"), expected: []byte("Hello"), }, { name: "empty", input: []byte{}, expected: []byte{}, }, { name: "only BOM", input: []byte{0xEF, 0xBB, 0xBF}, expected: []byte{}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := stripBOM(tc.input) assert.Equal(t, tc.expected, result) }) } } func TestDetectDelimiter(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "comma separated", input: "name,email,phone\nJohn,john@test.com,123\nJane,jane@test.com,456", expected: ",", }, { name: "semicolon separated", input: "name;email;phone\nJohn;john@test.com;123\nJane;jane@test.com;456", expected: ";", }, { name: "tab separated", input: "name\temail\tphone\nJohn\tjohn@test.com\t123\nJane\tjane@test.com\t456", expected: "\t", }, { name: "pipe separated", input: "name|email|phone\nJohn|john@test.com|123\nJane|jane@test.com|456", expected: "|", }, { name: "single line defaults to comma", input: "just a single line", expected: ",", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := detectDelimiter([]byte(tc.input)) assert.Equal(t, tc.expected, result) }) } } func TestDetectQuoteChar(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "double quotes", input: `"name","email"\n"John","john@test.com"`, expected: "\"", }, { name: "single quotes", input: `'name','email'\n'John','john@test.com'`, expected: "'", }, { name: "no quotes defaults to double", input: "name,email\nJohn,john@test.com", expected: "\"", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := detectQuoteChar([]byte(tc.input)) assert.Equal(t, tc.expected, result) }) } } func TestDetectDateFormat(t *testing.T) { tests := []struct { name string sampleDates []string expected string }{ { name: "ISO date", sampleDates: []string{"2024-01-15", "2024-02-20", "2024-03-25"}, expected: "2006-01-02", }, { name: "ISO datetime", sampleDates: []string{"2024-01-15T10:30:00", "2024-02-20T14:45:00"}, expected: "2006-01-02T15:04:05", }, { name: "European format", sampleDates: []string{"15.01.2024", "20.02.2024", "25.03.2024"}, expected: "02.01.2006", }, { name: "empty defaults to ISO", sampleDates: []string{}, expected: "2006-01-02", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := detectDateFormat(tc.sampleDates) assert.Equal(t, tc.expected, result) }) } } func TestSuggestMapping(t *testing.T) { tests := []struct { name string columns []string expected map[int]TaskAttribute }{ { name: "standard column names", columns: []string{"Title", "Description", "Due Date", "Priority", "Labels"}, expected: map[int]TaskAttribute{ 0: AttrTitle, 1: AttrDescription, 2: AttrDueDate, 3: AttrPriority, 4: AttrLabels, }, }, { name: "alternative column names", columns: []string{"Task Name", "Notes", "Deadline", "Tags", "Project"}, expected: map[int]TaskAttribute{ 0: AttrTitle, 1: AttrDescription, 2: AttrDueDate, 3: AttrLabels, 4: AttrProject, }, }, { name: "unknown columns", columns: []string{"ID", "Random Column", "Unknown"}, expected: map[int]TaskAttribute{ 0: AttrIgnore, 1: AttrIgnore, 2: AttrIgnore, }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { mappings := suggestMapping(tc.columns) require.Len(t, mappings, len(tc.columns)) for idx, expectedAttr := range tc.expected { assert.Equal(t, expectedAttr, mappings[idx].Attribute, "Column %d (%s)", idx, tc.columns[idx]) } }) } } func TestParseCSV(t *testing.T) { tests := []struct { name string input string delimiter string quoteChar string expectedCols []string expectedRows int expectedError bool }{ { name: "simple comma CSV", input: "name,email,phone\nJohn,john@test.com,123\nJane,jane@test.com,456", delimiter: ",", quoteChar: "\"", expectedCols: []string{"name", "email", "phone"}, expectedRows: 2, }, { name: "semicolon CSV", input: "name;email;phone\nJohn;john@test.com;123", delimiter: ";", quoteChar: "\"", expectedCols: []string{"name", "email", "phone"}, expectedRows: 1, }, { name: "quoted fields", input: "name,description\n\"John Doe\",\"A long, complicated description\"\nJane,Simple", delimiter: ",", quoteChar: "\"", expectedCols: []string{"name", "description"}, expectedRows: 2, }, { name: "with BOM", input: "\xEF\xBB\xBFname,email\nJohn,john@test.com", delimiter: ",", quoteChar: "\"", expectedCols: []string{"name", "email"}, expectedRows: 1, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { headers, rows, err := parseCSV([]byte(tc.input), tc.delimiter) if tc.expectedError { require.Error(t, err) return } require.NoError(t, err) assert.Equal(t, tc.expectedCols, headers) assert.Len(t, rows, tc.expectedRows) }) } } func TestParseBool(t *testing.T) { tests := []struct { input string expected bool }{ {"true", true}, {"True", true}, {"TRUE", true}, {"yes", true}, {"Yes", true}, {"1", true}, {"done", true}, {"completed", true}, {"false", false}, {"no", false}, {"0", false}, {"", false}, {"random", false}, } for _, tc := range tests { t.Run(tc.input, func(t *testing.T) { result := parseBool(tc.input) assert.Equal(t, tc.expected, result) }) } } func TestParsePriority(t *testing.T) { tests := []struct { input string expected int }{ {"0", 0}, {"1", 1}, {"3", 3}, {"5", 5}, {"10", 5}, // capped at 5 {"-1", 0}, // minimum 0 {"low", 2}, {"medium", 3}, {"high", 4}, {"urgent", 5}, {"highest", 5}, {"lowest", 1}, {"normal", 3}, {"random", 0}, } for _, tc := range tests { t.Run(tc.input, func(t *testing.T) { result := parsePriority(tc.input) assert.Equal(t, tc.expected, result) }) } } func TestParseLabels(t *testing.T) { tests := []struct { input string expected []string }{ {"work, personal, urgent", []string{"work", "personal", "urgent"}}, {"single", []string{"single"}}, {" spaced , labels ", []string{"spaced", "labels"}}, {"", []string{}}, {",,,", []string{}}, } for _, tc := range tests { t.Run(tc.input, func(t *testing.T) { result := parseLabels(tc.input) assert.Equal(t, tc.expected, result) }) } } func TestDetectCSVStructure(t *testing.T) { csvContent := `Title,Description,Due Date,Priority,Labels Task 1,Description 1,2024-01-15,high,work Task 2,Description 2,2024-01-20,low,"personal, urgent" Task 3,Description 3,2024-01-25,medium,home` reader := bytes.NewReader([]byte(csvContent)) result, err := DetectCSVStructure(reader, int64(len(csvContent))) require.NoError(t, err) assert.Equal(t, []string{"Title", "Description", "Due Date", "Priority", "Labels"}, result.Columns) assert.Equal(t, ",", result.Delimiter) assert.Len(t, result.SuggestedMapping, 5) assert.Len(t, result.PreviewRows, 3) // Check suggested mappings titleMapping := result.SuggestedMapping[0] assert.Equal(t, AttrTitle, titleMapping.Attribute) assert.Equal(t, "Title", titleMapping.ColumnName) descMapping := result.SuggestedMapping[1] assert.Equal(t, AttrDescription, descMapping.Attribute) dueDateMapping := result.SuggestedMapping[2] assert.Equal(t, AttrDueDate, dueDateMapping.Attribute) } func TestPreviewImport(t *testing.T) { csvContent := `Title,Description,Done,Priority Task 1,Description 1,true,high Task 2,Description 2,false,low Task 3,Description 3,yes,medium Task 4,Description 4,no,urgent Task 5,Description 5,1,normal Task 6,Description 6,0,low` config := ImportConfig{ Delimiter: ",", QuoteChar: "\"", DateFormat: "2006-01-02", Mapping: []ColumnMapping{ {ColumnIndex: 0, ColumnName: "Title", Attribute: AttrTitle}, {ColumnIndex: 1, ColumnName: "Description", Attribute: AttrDescription}, {ColumnIndex: 2, ColumnName: "Done", Attribute: AttrDone}, {ColumnIndex: 3, ColumnName: "Priority", Attribute: AttrPriority}, }, } reader := bytes.NewReader([]byte(csvContent)) result, err := PreviewImport(reader, int64(len(csvContent)), &config) require.NoError(t, err) assert.Equal(t, 6, result.TotalRows) assert.Len(t, result.Tasks, 5) // Preview limited to 5 // Check first task assert.Equal(t, "Task 1", result.Tasks[0].Title) assert.Equal(t, "Description 1", result.Tasks[0].Description) assert.True(t, result.Tasks[0].Done) assert.Equal(t, 4, result.Tasks[0].Priority) // "high" -> 4 // Check second task assert.Equal(t, "Task 2", result.Tasks[1].Title) assert.False(t, result.Tasks[1].Done) assert.Equal(t, 2, result.Tasks[1].Priority) // "low" -> 2 } func TestConvertToVikunja(t *testing.T) { rows := [][]string{ {"Task 1", "Description 1", "Project A"}, {"Task 2", "Description 2", "Project A"}, {"Task 3", "Description 3", "Project B"}, {"Task 4", "Description 4", ""}, // No project -> default } config := ImportConfig{ Delimiter: ",", QuoteChar: "\"", DateFormat: "2006-01-02", Mapping: []ColumnMapping{ {ColumnIndex: 0, Attribute: AttrTitle}, {ColumnIndex: 1, Attribute: AttrDescription}, {ColumnIndex: 2, Attribute: AttrProject}, }, } result := convertToVikunja(rows, &config) // Should have parent project + child projects require.GreaterOrEqual(t, len(result), 2) // First project should be the parent "Imported from CSV" assert.Equal(t, "Imported from CSV", result[0].Title) // Find Project A var projectA, projectB, tasksProject *struct { title string numTasks int } for _, p := range result[1:] { switch p.Title { case "Project A": projectA = &struct { title string numTasks int }{p.Title, len(p.Tasks)} case "Project B": projectB = &struct { title string numTasks int }{p.Title, len(p.Tasks)} case "Tasks": tasksProject = &struct { title string numTasks int }{p.Title, len(p.Tasks)} } } assert.NotNil(t, projectA, "Project A should exist") assert.Equal(t, 2, projectA.numTasks, "Project A should have 2 tasks") assert.NotNil(t, projectB, "Project B should exist") assert.Equal(t, 1, projectB.numTasks, "Project B should have 1 task") assert.NotNil(t, tasksProject, "Tasks project should exist for tasks without project") assert.Equal(t, 1, tasksProject.numTasks, "Tasks project should have 1 task") } func TestRowToTask(t *testing.T) { row := []string{"My Task", "Task description", "2024-01-15", "high", "work, urgent"} config := ImportConfig{ DateFormat: "2006-01-02", Mapping: []ColumnMapping{ {ColumnIndex: 0, Attribute: AttrTitle}, {ColumnIndex: 1, Attribute: AttrDescription}, {ColumnIndex: 2, Attribute: AttrDueDate}, {ColumnIndex: 3, Attribute: AttrPriority}, {ColumnIndex: 4, Attribute: AttrLabels}, }, } task := rowToTask(row, &config, 1) assert.Equal(t, "My Task", task.Title) assert.Equal(t, "Task description", task.Description) assert.Equal(t, 2024, task.DueDate.Year()) assert.Equal(t, 1, int(task.DueDate.Month())) assert.Equal(t, 15, task.DueDate.Day()) assert.Equal(t, int64(4), task.Priority) // "high" -> 4 require.Len(t, task.Labels, 2) assert.Equal(t, "work", task.Labels[0].Title) assert.Equal(t, "urgent", task.Labels[1].Title) } func TestMigratorName(t *testing.T) { m := &Migrator{} assert.Equal(t, "csv", m.Name()) } func TestEmptyFile(t *testing.T) { reader := bytes.NewReader([]byte{}) _, err := DetectCSVStructure(reader, 0) require.Error(t, err) } func TestRowToTaskWithMissingColumns(t *testing.T) { // Row with fewer columns than expected row := []string{"My Task"} config := ImportConfig{ Mapping: []ColumnMapping{ {ColumnIndex: 0, Attribute: AttrTitle}, {ColumnIndex: 1, Attribute: AttrDescription}, // Index 1 doesn't exist {ColumnIndex: 2, Attribute: AttrDueDate}, // Index 2 doesn't exist }, } task := rowToTask(row, &config, 1) // Should still work with available columns assert.Equal(t, "My Task", task.Title) assert.Empty(t, task.Description) assert.True(t, task.DueDate.IsZero()) } func TestRowToTaskWithEmptyTitle(t *testing.T) { row := []string{"", "Some description"} config := ImportConfig{ Mapping: []ColumnMapping{ {ColumnIndex: 0, Attribute: AttrTitle}, {ColumnIndex: 1, Attribute: AttrDescription}, }, } task := rowToTask(row, &config, 1) // Should have default title assert.Equal(t, "Untitled Task", task.Title) assert.Equal(t, "Some description", task.Description) } func TestDoneTask(t *testing.T) { row := []string{"Done Task", "completed"} config := ImportConfig{ Mapping: []ColumnMapping{ {ColumnIndex: 0, Attribute: AttrTitle}, {ColumnIndex: 1, Attribute: AttrDone}, }, } task := rowToTask(row, &config, 1) assert.Equal(t, "Done Task", task.Title) assert.True(t, task.Done) assert.False(t, task.DoneAt.IsZero()) // DoneAt should be set }