package msgraph import ( "context" "io" "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/warmbly/warmbly/internal/errx" "golang.org/x/oauth2" ) // graphStatus boots a client whose every Graph call answers with status. The // token is live, so nothing is refreshed and the status under test is what the // caller classifies. func graphStatus(t *testing.T, status int, body string) *Client { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = io.WriteString(w, body) })) t.Cleanup(srv.Close) c := &Client{Email: "sender@outlook.com"} ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{ Transport: rewriteTo(srv.URL), }) token := &oauth2.Token{AccessToken: "live", Expiry: time.Now().Add(time.Hour)} if merr := c.Init(ctx, token, oauth2.Config{}); merr != nil { t.Fatalf("Init: %v", merr.Message) } return c } // rewriteTo sends every request to the test server instead of graph.microsoft.com, // keeping the path and query the client actually built. func rewriteTo(base string) http.RoundTripper { target, err := url.Parse(base) if err != nil { panic(err) } return roundTripFunc(func(req *http.Request) (*http.Response, error) { clone := req.Clone(req.Context()) clone.URL.Scheme = target.Scheme clone.URL.Host = target.Host clone.Host = target.Host return http.DefaultTransport.RoundTrip(clone) }) } type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } // The classification the backfill's folder skip rests on. 404 is the only // status that means "this folder is not on the tenant"; every other refusal, // a 503 above all, has to stay an unreachable server so the caller retries // instead of writing the folder off. func TestHandleErrorSeparatesNotFoundFromUnreachable(t *testing.T) { cases := []struct { name string status int body string want errx.MailErrorCode }{ {"folder absent", http.StatusNotFound, `{"error":{"code":"ErrorItemNotFound","message":"The specified object was not found in the store."}}`, errx.MailErrorCodeNotFound}, {"graph incident", http.StatusServiceUnavailable, `{"error":{"code":"ServiceUnavailable","message":"Server busy."}}`, errx.MailErrorCodeServerUnreachable}, {"gateway", http.StatusBadGateway, `{}`, errx.MailErrorCodeServerUnreachable}, {"internal", http.StatusInternalServerError, `{}`, errx.MailErrorCodeServerUnreachable}, {"unrecognised", http.StatusTeapot, `{}`, errx.MailErrorCodeServerUnreachable}, {"expired grant", http.StatusUnauthorized, `{}`, errx.MailErrorCodeAuthenticationFailed}, {"missing scope", http.StatusForbidden, `{}`, errx.MailErrorCodeAuthorizationFailed}, {"throttled", http.StatusTooManyRequests, `{}`, errx.MailErrorCodeSendingTooFast}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { c := graphStatus(t, tc.status, tc.body) _, _, err := c.ListMessagesSince(context.Background(), FolderArchive, time.Now().Add(-24*time.Hour), "", 10) if got := mailErrorOf(t, err).Code; got != tc.want { t.Errorf("status %d classified as %s, want %s", tc.status, got, tc.want) } }) } } // FetchMessage keeps its own 404 handling: a message that vanished between the // delta item and the hydration is a skip, not an error. func TestFetchMessageStillTreatsNotFoundAsASkip(t *testing.T) { c := graphStatus(t, http.StatusNotFound, `{"error":{"code":"ErrorItemNotFound"}}`) msg, err := c.FetchMessage(context.Background(), FolderInbox, "gone") if err != nil { t.Fatalf("FetchMessage returned %v, want a silent skip", err) } if msg != nil { t.Fatalf("FetchMessage returned %+v, want nil", msg) } }