mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-08 16:02:18 +00:00
284 lines
9.9 KiB
Go
284 lines
9.9 KiB
Go
package wmail
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
// labelGmail is the Gmail API as the folder paths see it: an inbox listing
|
|
// and a minimal messages.get that answers with labels, or 404 when a message
|
|
// is not in labels at all.
|
|
type labelGmail struct {
|
|
inbox []string
|
|
labels map[string][]string
|
|
refuse map[string]int // messages.get answers this status instead
|
|
queries []url.Values
|
|
gets []string
|
|
}
|
|
|
|
func (g *labelGmail) serve(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if id, isGet := strings.CutPrefix(r.URL.Path, "/gmail/v1/users/me/messages/"); isGet {
|
|
g.gets = append(g.gets, id)
|
|
if code, ok := g.refuse[id]; ok {
|
|
w.WriteHeader(code)
|
|
_, _ = fmt.Fprintf(w, `{"error":{"code":%d,"message":"refused"}}`, code)
|
|
return
|
|
}
|
|
labels, ok := g.labels[id]
|
|
if !ok {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"error":{"code":404,"message":"Requested entity was not found."}}`))
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": id, "labelIds": labels})
|
|
return
|
|
}
|
|
g.queries = append(g.queries, r.URL.Query())
|
|
msgs := make([]map[string]string, 0, len(g.inbox))
|
|
for _, id := range g.inbox {
|
|
msgs = append(msgs, map[string]string{"id": id, "threadId": "t-" + id})
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"messages": msgs})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func folderUpdates(events []captured) map[uuid.UUID]string {
|
|
out := map[uuid.UUID]string{}
|
|
for _, e := range events {
|
|
if e.eventType != models.JobEventTypeFolderUpdate {
|
|
continue
|
|
}
|
|
u := e.body.(*models.JobEventFolderUpdate)
|
|
out[u.ID] = u.Folder
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Archiving in Gmail only removes the INBOX label, and that has to reach the
|
|
// platform as a move to archive, not only as a flag nobody files by.
|
|
func TestGmailLabelChangeReportsTheFolder(t *testing.T) {
|
|
g := &labelGmail{
|
|
labels: map[string][]string{
|
|
"gone-to-trash": {"TRASH", "UNREAD"},
|
|
"stale-record": {"CATEGORY_UPDATES"},
|
|
},
|
|
refuse: map[string]int{"refused": http.StatusBadRequest},
|
|
}
|
|
var events []captured
|
|
w := newGoogleTestMail(t, g.serve(t), &events)
|
|
rowID := uuid.New()
|
|
w.EmailMessageMapRepository = knownMessageMap{id: rowID.String()}
|
|
|
|
cases := []struct {
|
|
name string
|
|
id string
|
|
changed []string
|
|
current []string
|
|
added bool
|
|
want string
|
|
}{
|
|
{"archive", "m1", []string{"INBOX"}, []string{"CATEGORY_UPDATES"}, false, models.FolderArchive},
|
|
{"move to inbox", "m2", []string{"INBOX"}, []string{"INBOX", "UNREAD"}, true, models.FolderInbox},
|
|
{"report spam", "m3", []string{"SPAM"}, []string{"SPAM"}, true, models.FolderSpam},
|
|
{"labels looked up when the record has none", "gone-to-trash", []string{"TRASH"}, nil, true, models.FolderTrash},
|
|
{"labels that contradict the change are looked up", "stale-record", []string{"INBOX"}, []string{"INBOX"}, false, models.FolderArchive},
|
|
{"a star moves nothing", "m4", []string{"STARRED"}, []string{"INBOX", "STARRED"}, true, ""},
|
|
{"a lookup Gmail refuses leaves the folder to reconciliation", "refused", []string{"INBOX"}, nil, false, ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
events = nil
|
|
if err := w.emitGoogleLabelEvents(t.Context(), tc.id, tc.changed, tc.current, tc.added); err != nil {
|
|
t.Fatalf("emit: %v", err)
|
|
}
|
|
got, moved := folderUpdates(events)[rowID]
|
|
if tc.want == "" {
|
|
if moved {
|
|
t.Fatalf("reported a move to %q for a label that is not a folder", got)
|
|
}
|
|
return
|
|
}
|
|
if got != tc.want {
|
|
t.Fatalf("folder = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
if strings.Join(g.gets, ",") != "gone-to-trash,stale-record,refused" {
|
|
t.Errorf("looked up %v, want only the records whose labels could not be trusted", g.gets)
|
|
}
|
|
|
|
// Delete in Gmail adds TRASH and removes INBOX: one move, one report.
|
|
events = nil
|
|
current := []string{"TRASH"}
|
|
if err := w.emitGoogleLabelEvents(t.Context(), "m5", []string{"TRASH"}, current, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.emitGoogleLabelEvents(t.Context(), "m5", []string{"INBOX"}, current, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n := 0
|
|
for _, e := range events {
|
|
if e.eventType == models.JobEventTypeFolderUpdate {
|
|
n++
|
|
}
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("reported the move %d times, want once", n)
|
|
}
|
|
}
|
|
|
|
// providerRows stands in for the control plane's provider-folder listing.
|
|
type providerRows struct {
|
|
fakeSyncContext
|
|
rows []repository.ProviderFolderMessage
|
|
calls int
|
|
}
|
|
|
|
func (p *providerRows) ListProviderFolderMessages(_ context.Context, _, _ uuid.UUID, folders []string, _ int) ([]repository.ProviderFolderMessage, error) {
|
|
p.calls++
|
|
var out []repository.ProviderFolderMessage
|
|
for _, r := range p.rows {
|
|
if slices.Contains(folders, r.ProviderFolder) {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func removedIDs(events []captured) []uuid.UUID {
|
|
var out []uuid.UUID
|
|
for _, e := range events {
|
|
if e.eventType == models.JobEventTypeRemoveEmail {
|
|
out = append(out, e.body.(*models.JobEventRemoveEmail).ID)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Mail archived in Gmail before labels were followed stays in the unibox
|
|
// inbox until the reconciliation finds it.
|
|
func TestGmailReconcileMovesWhatGmailMoved(t *testing.T) {
|
|
now := time.Now()
|
|
row := func(providerID, folder string, age time.Duration) repository.ProviderFolderMessage {
|
|
return repository.ProviderFolderMessage{ID: uuid.New(), ProviderID: providerID, ProviderFolder: folder, InternalDate: now.Add(-age)}
|
|
}
|
|
stillInbox := row("still-inbox", models.FolderInbox, time.Hour)
|
|
archived := row("archived", models.FolderInbox, 2*time.Hour)
|
|
trashed := row("trashed", models.FolderInbox, 3*time.Hour)
|
|
refused := row("refused", models.FolderInbox, 3*time.Hour+30*time.Minute)
|
|
deleted := row("deleted", models.FolderInbox, 4*time.Hour)
|
|
backToInbox := row("back", models.FolderArchive, 5*time.Hour)
|
|
stillArchived := row("old-archive", models.FolderArchive, 6*time.Hour)
|
|
|
|
g := &labelGmail{
|
|
inbox: []string{"still-inbox", "back"},
|
|
labels: map[string][]string{
|
|
"archived": {"CATEGORY_PERSONAL"},
|
|
"trashed": {"TRASH"},
|
|
},
|
|
refuse: map[string]int{"refused": http.StatusBadRequest},
|
|
}
|
|
var events []captured
|
|
w := newGoogleTestMail(t, g.serve(t), &events)
|
|
sc := &providerRows{rows: []repository.ProviderFolderMessage{stillInbox, archived, trashed, refused, deleted, backToInbox, stillArchived}}
|
|
w.SyncContext = sc
|
|
|
|
if merr := w.googleReconcileFolders(t.Context(), now, &tickStats{}); merr != nil {
|
|
t.Fatalf("reconcile: %v", merr.Message)
|
|
}
|
|
|
|
want := map[uuid.UUID]string{
|
|
archived.ID: models.FolderArchive,
|
|
trashed.ID: models.FolderTrash,
|
|
backToInbox.ID: models.FolderInbox,
|
|
}
|
|
got := folderUpdates(events)
|
|
if len(got) != len(want) {
|
|
t.Fatalf("reported %d moves, want %d: %v", len(got), len(want), got)
|
|
}
|
|
for id, folder := range want {
|
|
if got[id] != folder {
|
|
t.Errorf("row %s moved to %q, want %q", id, got[id], folder)
|
|
}
|
|
}
|
|
// Gone from Gmail is removed, as the live feed's delete would.
|
|
if got := removedIDs(events); len(got) != 1 || got[0] != deleted.ID {
|
|
t.Errorf("removed %v, want only the deleted row", got)
|
|
}
|
|
// Only inbox rows missing from the listing cost a lookup, and one Gmail
|
|
// refuses does not stop the rows after it.
|
|
if strings.Join(g.gets, ",") != "archived,trashed,refused,deleted" {
|
|
t.Errorf("looked up %v, want archived, trashed, refused, deleted", g.gets)
|
|
}
|
|
// The bound reaches the oldest row of either set, here an archived one
|
|
// older than every inbox row, or its move back to the inbox is never seen.
|
|
wantQ := fmt.Sprintf("after:%d", stillArchived.InternalDate.Add(-24*time.Hour).Unix())
|
|
if len(g.queries) != 1 || g.queries[0].Get("labelIds") != "INBOX" || g.queries[0].Get("q") != wantQ {
|
|
t.Errorf("inbox listing queries = %v, want labelIds=INBOX and q=%s", g.queries, wantQ)
|
|
}
|
|
|
|
// Inside the interval nothing runs; after it, a message already found
|
|
// gone is not looked up again.
|
|
events, g.gets = nil, nil
|
|
if merr := w.googleReconcileFolders(t.Context(), now.Add(time.Minute), &tickStats{}); merr != nil {
|
|
t.Fatalf("second pass: %v", merr.Message)
|
|
}
|
|
if sc.calls != 2 {
|
|
t.Errorf("listed stored rows %d times inside the interval, want the first pass's 2", sc.calls)
|
|
}
|
|
sc.rows = []repository.ProviderFolderMessage{stillInbox, deleted}
|
|
if merr := w.googleReconcileFolders(t.Context(), now.Add(7*time.Hour), &tickStats{}); merr != nil {
|
|
t.Fatalf("third pass: %v", merr.Message)
|
|
}
|
|
if len(g.gets) != 0 {
|
|
t.Errorf("looked up %v again, want nothing", g.gets)
|
|
}
|
|
if len(events) != 0 {
|
|
t.Errorf("reported %d events for rows already where Gmail has them", len(events))
|
|
}
|
|
|
|
// A day on, the same message is worth one more look.
|
|
if merr := w.googleReconcileFolders(t.Context(), now.Add(25*time.Hour), &tickStats{}); merr != nil {
|
|
t.Fatalf("fourth pass: %v", merr.Message)
|
|
}
|
|
if strings.Join(g.gets, ",") != "deleted" {
|
|
t.Errorf("looked up %v a day later, want deleted", g.gets)
|
|
}
|
|
}
|
|
|
|
// A pass Gmail throttles is tried again soon, not after the full interval.
|
|
func TestGmailReconcileRetriesAFailedPassSoon(t *testing.T) {
|
|
now := time.Now()
|
|
g := &labelGmail{refuse: map[string]int{"throttled": http.StatusTooManyRequests}}
|
|
var events []captured
|
|
w := newGoogleTestMail(t, g.serve(t), &events)
|
|
w.SyncContext = &providerRows{rows: []repository.ProviderFolderMessage{
|
|
{ID: uuid.New(), ProviderID: "throttled", ProviderFolder: models.FolderInbox, InternalDate: now},
|
|
}}
|
|
|
|
if merr := w.googleReconcileFolders(t.Context(), now, &tickStats{}); merr == nil {
|
|
t.Fatal("a throttled lookup was swallowed")
|
|
}
|
|
g.gets = nil
|
|
if merr := w.googleReconcileFolders(t.Context(), now.Add(20*time.Minute), &tickStats{}); merr == nil || len(g.gets) != 1 {
|
|
t.Errorf("20 minutes on the pass did not run again (looked up %v)", g.gets)
|
|
}
|
|
}
|