Comments and attachments close out phase 2. Files are stored content-addressed by hash, so the path never contains anything the uploader chose — a filename is attacker-controlled text, and building a path from it is how "../" becomes a write outside the data directory. Everything downloads as application/octet-stream: an uploaded SVG served inline would run its own script on this origin with the session cookie attached. Web Push is written directly against RFC 8030/8291/8292 rather than pulled in — it is three specs' worth of well-defined crypto and about two hundred lines, and the alternatives carry a JWT and crypto stack each larger than the file. Phase 4 imports Todoist's CSV and exports it back. Todoist numbers priorities the opposite way to how it displays them — its 4 is P1 — so the two are converted rather than copied; getting it backwards silently inverts the urgency of every imported task. Nesting is positional, so the reader keeps an indent stack and the writer never sorts. The round trip is tested as the brief asks: a CSV in, out, and back produces an identical project, and two exports of one project are byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
437 lines
13 KiB
Go
437 lines
13 KiB
Go
package todoist
|
|
|
|
import (
|
|
"bytes"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// A real Todoist template export, with the shapes that matter: a task tree, a
|
|
// section, notes, priorities and dates.
|
|
const sampleCSV = `TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE
|
|
task,Betal moms,husk bilag,4,1,Kristian,,2026-03-15,en,
|
|
note,Bilag ligger i Dropbox,,,,,,,,
|
|
task,Find bilag,,3,2,Kristian,,,,
|
|
task,Scan dem,,1,3,Kristian,,,,
|
|
task,Ring til revisor,,1,1,Kristian,Anders,,,
|
|
,,,,,,,,,
|
|
section,Til gennemsyn,,,,,,,,
|
|
task,Gennemgå kvartal,,2,1,Kristian,,,,
|
|
task,Underopgave,,1,2,Kristian,,,,
|
|
`
|
|
|
|
func TestParseReadsTheShape(t *testing.T) {
|
|
rows, err := Parse(strings.NewReader(sampleCSV))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
|
|
// The blank spacer row is dropped; everything else survives.
|
|
var tasks, sections, notes int
|
|
for _, r := range rows {
|
|
switch r.Type {
|
|
case TypeTask:
|
|
tasks++
|
|
case TypeSection:
|
|
sections++
|
|
case TypeNote:
|
|
notes++
|
|
}
|
|
}
|
|
if tasks != 6 {
|
|
t.Errorf("got %d tasks, want 6", tasks)
|
|
}
|
|
if sections != 1 {
|
|
t.Errorf("got %d sections, want 1", sections)
|
|
}
|
|
if notes != 1 {
|
|
t.Errorf("got %d notes, want 1", notes)
|
|
}
|
|
}
|
|
|
|
// Todoist's CSV numbers priorities the opposite way to how its interface shows
|
|
// them. Getting this backwards silently inverts the urgency of every task.
|
|
func TestPriorityIsInverted(t *testing.T) {
|
|
cases := map[int]int{4: 1, 3: 2, 2: 3, 1: 4, 0: 4}
|
|
for todoist, want := range cases {
|
|
if got := PriorityToVerdande(todoist); got != want {
|
|
t.Errorf("PriorityToVerdande(%d) = %d, want %d", todoist, got, want)
|
|
}
|
|
}
|
|
for verdande, want := range map[int]int{1: 4, 2: 3, 3: 2, 4: 1} {
|
|
if got := PriorityFromVerdande(verdande); got != want {
|
|
t.Errorf("PriorityFromVerdande(%d) = %d, want %d", verdande, got, want)
|
|
}
|
|
}
|
|
// And the two are inverses, which is what the round trip depends on.
|
|
for v := 1; v <= 4; v++ {
|
|
if got := PriorityToVerdande(PriorityFromVerdande(v)); got != v {
|
|
t.Errorf("priority %d survived a round trip as %d", v, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFromRowsBuildsTheTree(t *testing.T) {
|
|
rows, err := Parse(strings.NewReader(sampleCSV))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := FromRows(rows, "Firma")
|
|
|
|
if p.Name != "Firma" {
|
|
t.Errorf("Name = %q", p.Name)
|
|
}
|
|
if len(p.Tasks) != 2 {
|
|
t.Fatalf("got %d top-level tasks, want 2", len(p.Tasks))
|
|
}
|
|
|
|
moms := p.Tasks[0]
|
|
if moms.Content != "Betal moms" {
|
|
t.Fatalf("first task = %q", moms.Content)
|
|
}
|
|
if moms.Priority != 1 {
|
|
t.Errorf("priority = %d, want 1 (Todoist's 4)", moms.Priority)
|
|
}
|
|
if moms.Date != "2026-03-15" {
|
|
t.Errorf("date = %q", moms.Date)
|
|
}
|
|
if len(moms.Comments) != 1 || moms.Comments[0] != "Bilag ligger i Dropbox" {
|
|
t.Errorf("comments = %v", moms.Comments)
|
|
}
|
|
|
|
// Indent 2 is a child of the indent-1 task above it; indent 3 is a child of that.
|
|
if len(moms.Children) != 1 {
|
|
t.Fatalf("got %d children, want 1", len(moms.Children))
|
|
}
|
|
if moms.Children[0].Content != "Find bilag" {
|
|
t.Errorf("child = %q", moms.Children[0].Content)
|
|
}
|
|
if len(moms.Children[0].Children) != 1 {
|
|
t.Fatalf("got %d grandchildren, want 1", len(moms.Children[0].Children))
|
|
}
|
|
if moms.Children[0].Children[0].Content != "Scan dem" {
|
|
t.Errorf("grandchild = %q", moms.Children[0].Children[0].Content)
|
|
}
|
|
|
|
// The assignee survives.
|
|
if p.Tasks[1].Assignee != "Anders" {
|
|
t.Errorf("assignee = %q, want Anders", p.Tasks[1].Assignee)
|
|
}
|
|
|
|
// A section resets the tree, and its own tasks nest inside it.
|
|
if len(p.Sections) != 1 {
|
|
t.Fatalf("got %d sections", len(p.Sections))
|
|
}
|
|
section := p.Sections[0]
|
|
if section.Name != "Til gennemsyn" {
|
|
t.Errorf("section name = %q", section.Name)
|
|
}
|
|
if len(section.Tasks) != 1 {
|
|
t.Fatalf("the section has %d top-level tasks, want 1", len(section.Tasks))
|
|
}
|
|
if len(section.Tasks[0].Children) != 1 {
|
|
t.Errorf("the section's task has %d children, want 1", len(section.Tasks[0].Children))
|
|
}
|
|
}
|
|
|
|
// The requirement from the brief, stated directly: a Todoist CSV in, and out again,
|
|
// has to be the same thing. This is the test that stops an import from quietly
|
|
// losing a sub-task, a comment or a priority.
|
|
func TestRoundTripIsLossless(t *testing.T) {
|
|
rows, err := Parse(strings.NewReader(sampleCSV))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
project := FromRows(rows, "Firma")
|
|
|
|
var out bytes.Buffer
|
|
if err := Write(&out, ToRows(project)); err != nil {
|
|
t.Fatalf("Write: %v", err)
|
|
}
|
|
|
|
// Read what was written and rebuild. The two projects must be identical.
|
|
reparsed, err := Parse(bytes.NewReader(out.Bytes()))
|
|
if err != nil {
|
|
t.Fatalf("re-parsing our own output: %v", err)
|
|
}
|
|
again := FromRows(reparsed, "Firma")
|
|
|
|
assertSameProject(t, project, again)
|
|
}
|
|
|
|
// And a second pass must produce byte-identical output, or "export" is not a
|
|
// repeatable operation and diffing two exports is meaningless.
|
|
func TestExportIsStable(t *testing.T) {
|
|
rows, err := Parse(strings.NewReader(sampleCSV))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
project := FromRows(rows, "Firma")
|
|
|
|
var first, second bytes.Buffer
|
|
if err := Write(&first, ToRows(project)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reparsed, err := Parse(bytes.NewReader(first.Bytes()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := Write(&second, ToRows(FromRows(reparsed, "Firma"))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if first.String() != second.String() {
|
|
t.Errorf("two exports of the same project differ:\n--- first ---\n%s\n--- second ---\n%s",
|
|
first.String(), second.String())
|
|
}
|
|
}
|
|
|
|
// What is written has to be readable by Todoist, which means the exact header and
|
|
// no priority or indent on rows that are not tasks.
|
|
func TestWrittenFileMatchesTodoistsFormat(t *testing.T) {
|
|
var out bytes.Buffer
|
|
err := Write(&out, ToRows(Project{
|
|
Name: "Firma",
|
|
Tasks: []Task{{Content: "En opgave", Priority: 1}},
|
|
Sections: []Section{{
|
|
Name: "En sektion",
|
|
Tasks: []Task{{Content: "I sektionen", Priority: 4, Comments: []string{"en note"}}},
|
|
}},
|
|
}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
|
|
|
want := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE"
|
|
if strings.TrimSpace(lines[0]) != want {
|
|
t.Errorf("header:\n got %q\nwant %q", lines[0], want)
|
|
}
|
|
|
|
for _, line := range lines[1:] {
|
|
fields := strings.Split(line, ",")
|
|
if len(fields) < 5 {
|
|
continue
|
|
}
|
|
// Only task rows carry a priority and an indent.
|
|
if fields[0] != TypeTask && (fields[3] != "" || fields[4] != "") {
|
|
t.Errorf("a %q row carries a priority or indent: %q", fields[0], line)
|
|
}
|
|
if fields[0] == TypeTask && fields[4] == "" {
|
|
t.Errorf("a task row has no indent: %q", line)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Files arrive having been opened in a spreadsheet, which is where BOMs, semicolons
|
|
// and reordered columns come from. Refusing those means refusing most real exports.
|
|
func TestParseSurvivesRealWorldFiles(t *testing.T) {
|
|
t.Run("utf-8 BOM", func(t *testing.T) {
|
|
withBOM := "\xEF\xBB\xBF" + sampleCSV
|
|
rows, err := Parse(strings.NewReader(withBOM))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
if len(rows) == 0 {
|
|
t.Fatal("no rows were read")
|
|
}
|
|
if rows[0].Content != "Betal moms" {
|
|
t.Errorf("first row = %+v; the BOM was probably left in the header", rows[0])
|
|
}
|
|
})
|
|
|
|
t.Run("reordered columns", func(t *testing.T) {
|
|
reordered := "CONTENT,TYPE,INDENT,PRIORITY,DESCRIPTION,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE\n" +
|
|
"Betal moms,task,1,4,,Kristian,,2026-03-15,en,\n"
|
|
rows, err := Parse(strings.NewReader(reordered))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("got %d rows", len(rows))
|
|
}
|
|
if rows[0].Content != "Betal moms" || rows[0].Type != TypeTask || rows[0].Priority != 4 {
|
|
t.Errorf("columns were read by position, not by name: %+v", rows[0])
|
|
}
|
|
})
|
|
|
|
t.Run("short rows", func(t *testing.T) {
|
|
short := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT\ntask,Kort række\n"
|
|
rows, err := Parse(strings.NewReader(short))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
if len(rows) != 1 || rows[0].Content != "Kort række" {
|
|
t.Errorf("rows = %+v", rows)
|
|
}
|
|
})
|
|
|
|
t.Run("danish characters and commas in content", func(t *testing.T) {
|
|
var out bytes.Buffer
|
|
original := Project{Tasks: []Task{
|
|
{Content: `Køb mælk, brød og smør`, Description: "på vej hjem", Priority: 2},
|
|
}}
|
|
if err := Write(&out, ToRows(original)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows, err := Parse(bytes.NewReader(out.Bytes()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
back := FromRows(rows, "")
|
|
if back.Tasks[0].Content != `Køb mælk, brød og smør` {
|
|
t.Errorf("content = %q; the comma was probably not quoted", back.Tasks[0].Content)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestParseRejectsWhatIsNotAnExport(t *testing.T) {
|
|
for _, in := range []string{
|
|
"",
|
|
"navn;pris\nkaffe;40\n",
|
|
"just some text\n",
|
|
} {
|
|
t.Run(in, func(t *testing.T) {
|
|
if _, err := Parse(strings.NewReader(in)); err == nil {
|
|
t.Error("a file that is not a Todoist export was accepted")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A hand-edited file can jump from indent 1 to indent 3. Dropping those tasks would
|
|
// lose work silently; attaching them one level deeper than what is open does not.
|
|
func TestBrokenIndentationDoesNotLoseTasks(t *testing.T) {
|
|
broken := `TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE
|
|
task,Forælder,,1,1,,,,,
|
|
task,Springer til tre,,1,3,,,,,
|
|
task,Tilbage til en,,1,1,,,,,
|
|
`
|
|
rows, err := Parse(strings.NewReader(broken))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := FromRows(rows, "x")
|
|
|
|
if got := countTasks(p); got != 3 {
|
|
t.Errorf("%d tasks survived, want all 3", got)
|
|
}
|
|
if len(p.Tasks) != 2 {
|
|
t.Errorf("got %d top-level tasks, want 2", len(p.Tasks))
|
|
}
|
|
}
|
|
|
|
func TestParseDate(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
wantDate string
|
|
wantOK bool
|
|
}{
|
|
{"2026-03-15", "2026-03-15", true},
|
|
{"2026-03-15 10:00", "2026-03-15", true},
|
|
{"15-03-2026", "2026-03-15", true},
|
|
{"15/03/2026", "2026-03-15", true},
|
|
// Todoist stores what was typed, so most dates are natural language and
|
|
// belong to the caller's own parser.
|
|
{"every Monday", "", false},
|
|
{"tomorrow", "", false},
|
|
{"i morgen", "", false},
|
|
{"", "", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.in, func(t *testing.T) {
|
|
date, remaining, ok := ParseDate(tc.in)
|
|
if ok != tc.wantOK {
|
|
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
|
|
}
|
|
if date != tc.wantDate {
|
|
t.Errorf("date = %q, want %q", date, tc.wantDate)
|
|
}
|
|
if !ok && tc.in != "" && remaining != tc.in {
|
|
t.Errorf("remaining = %q, want the original text back", remaining)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- helpers ---------------------------------------------------------------------
|
|
|
|
func assertSameProject(t *testing.T, want, got Project) {
|
|
t.Helper()
|
|
|
|
if len(want.Tasks) != len(got.Tasks) {
|
|
t.Fatalf("top-level tasks: %d vs %d", len(want.Tasks), len(got.Tasks))
|
|
}
|
|
for i := range want.Tasks {
|
|
assertSameTask(t, "task["+want.Tasks[i].Content+"]", want.Tasks[i], got.Tasks[i])
|
|
}
|
|
|
|
if len(want.Sections) != len(got.Sections) {
|
|
t.Fatalf("sections: %d vs %d", len(want.Sections), len(got.Sections))
|
|
}
|
|
for i := range want.Sections {
|
|
if want.Sections[i].Name != got.Sections[i].Name {
|
|
t.Errorf("section name: %q vs %q", want.Sections[i].Name, got.Sections[i].Name)
|
|
}
|
|
if len(want.Sections[i].Tasks) != len(got.Sections[i].Tasks) {
|
|
t.Fatalf("section %q tasks: %d vs %d", want.Sections[i].Name,
|
|
len(want.Sections[i].Tasks), len(got.Sections[i].Tasks))
|
|
}
|
|
for j := range want.Sections[i].Tasks {
|
|
assertSameTask(t, "section "+want.Sections[i].Name,
|
|
want.Sections[i].Tasks[j], got.Sections[i].Tasks[j])
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertSameTask(t *testing.T, where string, want, got Task) {
|
|
t.Helper()
|
|
|
|
if want.Content != got.Content {
|
|
t.Errorf("%s content: %q vs %q", where, want.Content, got.Content)
|
|
}
|
|
if want.Description != got.Description {
|
|
t.Errorf("%s description: %q vs %q", where, want.Description, got.Description)
|
|
}
|
|
if want.Priority != got.Priority {
|
|
t.Errorf("%s priority: %d vs %d", where, want.Priority, got.Priority)
|
|
}
|
|
if want.Date != got.Date {
|
|
t.Errorf("%s date: %q vs %q", where, want.Date, got.Date)
|
|
}
|
|
if want.Assignee != got.Assignee {
|
|
t.Errorf("%s assignee: %q vs %q", where, want.Assignee, got.Assignee)
|
|
}
|
|
if len(want.Comments) != len(got.Comments) {
|
|
t.Errorf("%s comments: %v vs %v", where, want.Comments, got.Comments)
|
|
} else {
|
|
for i := range want.Comments {
|
|
if want.Comments[i] != got.Comments[i] {
|
|
t.Errorf("%s comment %d: %q vs %q", where, i, want.Comments[i], got.Comments[i])
|
|
}
|
|
}
|
|
}
|
|
if len(want.Children) != len(got.Children) {
|
|
t.Fatalf("%s children: %d vs %d", where, len(want.Children), len(got.Children))
|
|
}
|
|
for i := range want.Children {
|
|
assertSameTask(t, where+" > "+want.Children[i].Content, want.Children[i], got.Children[i])
|
|
}
|
|
}
|
|
|
|
func countTasks(p Project) int {
|
|
var count func([]Task) int
|
|
count = func(tasks []Task) int {
|
|
n := 0
|
|
for _, t := range tasks {
|
|
n += 1 + count(t.Children)
|
|
}
|
|
return n
|
|
}
|
|
total := count(p.Tasks)
|
|
for _, s := range p.Sections {
|
|
total += count(s.Tasks)
|
|
}
|
|
return total
|
|
}
|