package sync import ( "context" "encoding/base64" "errors" "testing" "time" "unicode/utf8" ) func TestRetrySucceedsOnSecondTry(t *testing.T) { attempts := 0 err := Retry(context.Background(), RetryPolicy{BaseDelay: time.Millisecond, MaxDelay: 5 * time.Millisecond}, func() error { attempts++ if attempts == 1 { return retryWrap(errors.New("boom")) } return nil }) if err != nil { t.Fatalf("unexpected error: %v", err) } if attempts != 2 { t.Fatalf("expected 2 attempts, got %d", attempts) } } func TestRetryExhaustsAttempts(t *testing.T) { attempts := 0 err := Retry(context.Background(), RetryPolicy{MaxAttempts: 3, BaseDelay: time.Millisecond, MaxDelay: 5 * time.Millisecond}, func() error { attempts++ return retryWrap(errors.New("nope")) }) if err == nil { t.Fatal("expected error after exhaustion") } if attempts != 3 { t.Fatalf("expected 3 attempts, got %d", attempts) } } func TestRetryNonRetriableAbortsImmediately(t *testing.T) { attempts := 0 err := Retry(context.Background(), RetryPolicy{MaxAttempts: 5, BaseDelay: time.Millisecond}, func() error { attempts++ return errors.New("permanent") }) if err == nil { t.Fatal("expected error") } if attempts != 1 { t.Fatalf("expected 1 attempt for non-retriable, got %d", attempts) } } func TestRetryRespectsContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() err := Retry(ctx, RetryPolicy{MaxAttempts: 5, BaseDelay: time.Millisecond}, func() error { return retryWrap(errors.New("x")) }) if !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled, got %v", err) } } func TestDelayGrows(t *testing.T) { p := RetryPolicy{BaseDelay: time.Second, MaxDelay: 30 * time.Second, Jitter: 0} d1 := p.delay(1) // attempt 1 => 0 d2 := p.delay(2) d3 := p.delay(3) if d1 != 0 { t.Fatalf("attempt 1 delay should be 0, got %v", d1) } if d2 != time.Second { t.Fatalf("attempt 2 delay should be 1s, got %v", d2) } if d3 != 2*time.Second { t.Fatalf("attempt 3 delay should be 2s, got %v", d3) } } func TestSanitize(t *testing.T) { cases := map[string]string{ "a/b\\c:d*e": "a_b_c_d_e", "normal.txt": "normal.txt", "../evil": ".._evil", "a b c.pdf": "a_b_c.pdf", } for in, want := range cases { if got := sanitize(in); got != want { t.Errorf("sanitize(%q) = %q, want %q", in, got, want) } } } func TestIsICS(t *testing.T) { if !isICS("reply.ics") || !isICS("x.ICAL") { t.Fatal("ics extensions not detected") } if isICS("invoice.pdf") { t.Fatal("pdf misdetected as ics") } } const fixtureReplyICS = `BEGIN:VCALENDAR METHOD:REPLY PRODID:Microsoft Exchange Server 2010 VERSION:2.0 BEGIN:VTIMEZONE TZID:W. Europe Standard Time BEGIN:STANDARD DTSTART:16010101T030000 TZOFFSETFROM:+0200 TZOFFSETTO:+0100 RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T020000 TZOFFSETFROM:+0100 TZOFFSETTO:+0200 RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3 END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT ATTENDEE;PARTSTAT=ACCEPTED;CN="Baker, Ben":mailto:bbaker1@teksystems.com UID:bvlnr1i35ug30kn6rvu9dop00g@google.com SUMMARY;LANGUAGE=en-US:Accepted: Appointment (Ben Baker) DTSTART;TZID=W. Europe Standard Time:20260812T120000 DTEND;TZID=W. Europe Standard Time:20260812T123000 CLASS:PUBLIC STATUS:CONFIRMED LOCATION;LANGUAGE=en-US:https://meet.google.com/sxh-ubud-jrd END:VEVENT END:VCALENDAR` func TestICSToMarkdown(t *testing.T) { out := ICSToMarkdown([]byte(fixtureReplyICS)) for _, want := range []string{ "Accepted: Appointment", "When:", "Where:", "meet.google.com", "Attendee:", "Baker, Ben", "Accepted", "Calendar method: REPLY", } { if !contains(out, want) { t.Errorf("output missing %q:\n%s", want, out) } } if contains(out, "BEGIN:VCALENDAR") { t.Errorf("raw ICS leaked into markdown:\n%s", out) } } func TestICSToMarkdownFallback(t *testing.T) { out := ICSToMarkdown([]byte("not a calendar")) if !contains(out, "not a calendar") { t.Fatalf("expected raw fallback, got %q", out) } } func TestICSToMarkdownNormalizesLatin1(t *testing.T) { // Real-world ICS from a rental portal: summary in UTF-8, location Latin-1 // ("N\xfcrnberg"). The markdown output must be valid UTF-8 everywhere. raw := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n" + "SUMMARY:Mietwagen-Buchung: N\xc3\xbcrnberg\r\n" + "LOCATION:N\xfcrnberg\r\nDTSTART:20200101T090000Z\r\nDTEND:20200101T180000Z\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" out := ICSToMarkdown([]byte(raw)) if !utf8.ValidString(out) { t.Fatalf("output is not valid UTF-8:\n%q", out) } if !contains(out, "Nürnberg") { t.Errorf("expected Nürnberg in output:\n%s", out) } if contains(out, "N\xfcrnberg") { t.Errorf("Latin-1 bytes leaked into output:\n%q", out) } } func TestICSToMarkdownAllDay(t *testing.T) { ics := `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:y@google.com SUMMARY:All day thing DTSTART;VALUE=DATE:20260815 DTEND;VALUE=DATE:20260816 END:VEVENT END:VCALENDAR` out := ICSToMarkdown([]byte(ics)) if !contains(out, "All day thing") || !contains(out, "2026-08-15") { t.Errorf("all-day event not parsed:\n%s", out) } } func TestCollectParts(t *testing.T) { p := gmailPart{ MimeType: "multipart/mixed", Parts: []gmailPart{ {MimeType: "multipart/alternative", Parts: []gmailPart{ {MimeType: "text/plain", Body: gmailBody{Data: b64("plain text")}}, {MimeType: "text/html", Body: gmailBody{Data: b64("

html

")}}, }}, {PartID: "2", MimeType: "application/pdf", Filename: "invoice.pdf", Body: gmailBody{Size: 100}}, }, } text, html, atts := collectParts(p, "root", "abc123", 0) if text != "plain text" { t.Errorf("text = %q", text) } if html != "

html

" { t.Errorf("html = %q", html) } if len(atts) != 1 || atts[0].FileName != "invoice.pdf" || atts[0].FileID != "abc123:2" { t.Errorf("atts = %+v", atts) } } type fakeGmailAPI struct { lastQ string lastLimit int ids []string } func (f *fakeGmailAPI) ListIDs(_ context.Context, q string, maxIDs int, _ string) ([]string, string, error) { f.lastQ = q f.lastLimit = maxIDs return f.ids, "", nil } func (f *fakeGmailAPI) GetMessage(context.Context, string) (*Message, error) { return nil, errors.New("unused") } func (f *fakeGmailAPI) DownloadAttachment(context.Context, string, string) ([]byte, error) { return nil, errors.New("unused") } func TestGmailSourcePassesQueryToListIDs(t *testing.T) { fake := &fakeGmailAPI{ids: []string{"m1"}} src := &gmailSource{c: fake, query: "from:alice@example.com"} ids, _, err := src.ListIDs(context.Background(), 10, "") if err != nil { t.Fatal(err) } if fake.lastQ != "from:alice@example.com" { t.Fatalf("ListIDs q=%q, want from:alice@example.com", fake.lastQ) } if fake.lastLimit != 10 { t.Fatalf("ListIDs limit=%d, want 10", fake.lastLimit) } if len(ids) != 1 || ids[0] != "m1" { t.Fatalf("ids=%v", ids) } } func TestGmailSourceEmptyQueryDefaultsToInbox(t *testing.T) { fake := &fakeGmailAPI{} src := &gmailSource{c: fake, query: ""} if _, _, err := src.ListIDs(context.Background(), 5, ""); err != nil { t.Fatal(err) } if fake.lastQ != "in:inbox" { t.Fatalf("empty query q=%q, want in:inbox", fake.lastQ) } } func TestParseCLIGmailQuery(t *testing.T) { cli, code, err := ParseCLI([]string{ "--source", "gmail", "--query", "from:letrado@example.com", "--out", t.TempDir(), "--dry-run", }) if err != nil || code != 0 { t.Fatalf("ParseCLI: code=%d err=%v", code, err) } if cli.Sync.Query != "from:letrado@example.com" { t.Fatalf("query=%q", cli.Sync.Query) } if cli.Sync.Gmail == nil { t.Fatal("gmail source not configured") } } func b64(s string) string { return base64.URLEncoding.EncodeToString([]byte(s)) } func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0) } func indexOf(s, sub string) int { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { return i } } return -1 }