package console import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "strconv" "strings" "sync" "testing" "time" "github.com/carlosframework/platform/internal/mail" "github.com/carlosframework/platform/internal/release" ) // The scheme has one executable definition, and both ends are held to it: a // body signed by the console verifies with the key the app is given, and // nothing about it can be changed on the way. func TestAMailEventSignatureVerifiesAndNothingElseDoes(t *testing.T) { fx := newConsoleFixture(t) key, err := fx.st.MailEventsSigningKey() if err != nil { t.Fatal(err) } again, _ := fx.st.MailEventsSigningKey() if mailEventsPublicKey(again) != mailEventsPublicKey(key) { t.Fatal("the signing key changed between reads — every app's delivered key would stop verifying") } pub := mailEventsPublicKey(key) now := time.Date(2026, 9, 15, 9, 0, 0, 0, time.UTC) body := []byte(`{"id":"n1","account":"beb","app":"jb-book","type":"Bounce"}`) header := signMailEvent(key, now, body) if err := VerifyMailEvent(pub, header, body, now.Add(time.Minute), 5*time.Minute); err != nil { t.Fatalf("a genuine delivery did not verify: %v", err) } other, _ := newConsoleFixture(t).st.MailEventsSigningKey() for name, c := range map[string]struct { key, header string body []byte at time.Time }{ "a changed body": {pub, header, []byte(strings.Replace(string(body), "jb-book", "jb-site", 1)), now}, "another deployment's key": {mailEventsPublicKey(other), header, body, now}, "a replay long after": {pub, header, body, now.Add(10 * time.Minute)}, "a timestamp from the future": {pub, header, body, now.Add(-10 * time.Minute)}, "a moved timestamp": {pub, strings.Replace(header, "t=", "t=1", 1), body, now}, "no signature at all": {pub, "t=1726390800", body, now}, } { if err := VerifyMailEvent(c.key, c.header, c.body, c.at, 5*time.Minute); err == nil { t.Errorf("%s verified", name) } } } // pushFixture is one app on one domain, asking to be told, with the push // pointed at a test server instead of the app's platform host. func pushFixture(t *testing.T, handler http.HandlerFunc) (*consoleFixture, *httptest.Server) { t.Helper() fx := newConsoleFixture(t) ctx := context.Background() now := time.Now() for _, app := range []string{"jb-book", "jb-site"} { if _, err := fx.st.CreateAppIn(OpsAccountID, app, "bill@tito.io", now); err != nil { t.Fatal(err) } } rec := &mail.DomainRecord{ Domain: "lgc.li", Kind: mail.KindCustom, Apps: []string{"jb-book", "jb-site"}, Regions: map[string]*mail.RegionState{"eu-west-1": { IdentityARN: "arn:aws:ses:eu-west-1:1:identity/lgc.li", MailFrom: "mail-eu-west-1.lgc.li", Status: mail.StatusVerified, }}, CreatedAt: now, } if _, err := mail.PutDomain(ctx, fx.rel, release.OpsAccount, rec, ""); err != nil { t.Fatal(err) } for _, app := range []string{"jb-book", "jb-site"} { if err := fx.st.SetMailEventHook(ctx, release.OpsAccount, app, "/mail/events", "bill@tito.io", now); err != nil { t.Fatal(err) } } srv := httptest.NewServer(handler) t.Cleanup(srv.Close) fx.srv.mailPushTarget = func(acct, app, path string) string { return srv.URL + path + "?app=" + app } return fx, srv } func sentBy(app string, ev mail.Event) mail.Event { ev.Caller = mail.UserName(release.OpsAccount, app) return ev } func queued(t *testing.T, fx *consoleFixture, app string) int { t.Helper() var n int if err := fx.st.db.QueryRow(`SELECT count(*) FROM mail_event_deliveries WHERE app = ?`, app).Scan(&n); err != nil { t.Fatal(err) } return n } // Only an event SES attributed to the app itself is ever queued for it, and // only a type worth telling. A notification SNS delivers twice is queued // once. func TestOnlyTheSendersOwnOutcomesAreQueued(t *testing.T) { fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) {}) ctx := context.Background() now := time.Now() bounce := sentBy("jb-book", bounceTo("ann@example.com", "m-ann", now)) fx.srv.recordAndEnforceMail(ctx, bounce, "sns-1") fx.srv.recordAndEnforceMail(ctx, bounce, "sns-1") if n := queued(t, fx, "jb-book"); n != 1 { t.Errorf("jb-book queued %d, want 1 — a redelivered notification is one delivery", n) } if n := queued(t, fx, "jb-site"); n != 0 { t.Errorf("jb-site was queued another app's recipient: %d", n) } // Attributed only by the shared domain: kept in both logs, pushed to // neither. fx.srv.recordAndEnforceMail(ctx, bounceTo("bob@example.com", "m-bob", now), "sns-2") if n := queued(t, fx, "jb-book") + queued(t, fx, "jb-site"); n != 1 { t.Errorf("a domain-attributed event was queued (%d rows in all)", n-1) } // A Send is not news to the app that sent it. send := sentBy("jb-book", mail.Event{Type: mail.EventSend, Domain: "lgc.li", MessageID: "m-cat", At: now, Destination: []string{"cat@example.com"}}) fx.srv.recordAndEnforceMail(ctx, send, "sns-3") if n := queued(t, fx, "jb-book"); n != 1 { t.Errorf("a Send was queued") } // And an app that has not asked is not queued anything. if _, err := fx.st.DeleteMailEventHook(ctx, release.OpsAccount, "jb-book", now); err != nil { t.Fatal(err) } fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("dan@example.com", "m-dan", now)), "sns-4") if n := queued(t, fx, "jb-book"); n != 1 { t.Errorf("an app with no hook was queued") } } // A delivery lands at the app's path, signed with the key the app was // given, carrying who it was about and what the receiving server said. func TestADeliveryReachesTheAppSigned(t *testing.T) { var mu sync.Mutex var gotPath, gotSig string var gotBody []byte fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() gotPath = r.URL.Path + "?" + r.URL.RawQuery gotSig = r.Header.Get("Carlos-Signature") gotBody, _ = io.ReadAll(r.Body) w.WriteHeader(http.StatusNoContent) }) ctx := context.Background() now := time.Now() fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", "m-ann", now)), "sns-1") if err := fx.srv.mailPushPass(ctx, time.Now); err != nil { t.Fatal(err) } mu.Lock() defer mu.Unlock() if gotPath != "/mail/events?app=jb-book" { t.Fatalf("delivered to %q", gotPath) } key, _ := fx.st.MailEventsSigningKey() if err := VerifyMailEvent(mailEventsPublicKey(key), gotSig, gotBody, time.Now(), 5*time.Minute); err != nil { t.Fatalf("the app could not verify what it was sent: %v", err) } var p MailEventPayload if err := json.Unmarshal(gotBody, &p); err != nil { t.Fatal(err) } if p.ID != "sns-1" || p.Account != release.OpsAccount || p.App != "jb-book" || p.Type != mail.EventBounce || p.MessageID != "m-ann" || p.Detail != "Permanent/NoEmail" { t.Errorf("payload = %+v", p) } if len(p.Recipients) != 1 || p.Recipients[0].Address != "ann@example.com" || p.Recipients[0].Status != "5.1.1" { t.Errorf("recipients = %+v", p.Recipients) } st, _ := fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", time.Time{}) if st.Delivered != 1 || st.Pending != 0 || st.LastDelivered.IsZero() { t.Errorf("status after delivery = %+v", st) } // Delivered is delivered: the next pass sends nothing. gotPath = "" mu.Unlock() fx.srv.mailPushPass(ctx, time.Now) mu.Lock() if gotPath != "" { t.Error("a delivered event was sent again") } } // A delivery that does not land is tried again on a widening schedule, and // given up on when the schedule is spent — never retried for ever, and never // kept past the log it came from. func TestAFailedDeliveryBacksOffAndIsGivenUpOn(t *testing.T) { var mu sync.Mutex hits := 0 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) { mu.Lock() hits++ mu.Unlock() w.WriteHeader(http.StatusServiceUnavailable) }) ctx := context.Background() now := time.Date(2026, 9, 15, 9, 0, 0, 0, time.UTC) fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", "m-ann", now)), "sns-1") // Queued by the real clock; start the fake one there. var created int64 fx.st.db.QueryRow(`SELECT created_at FROM mail_event_deliveries`).Scan(&created) clock := time.Unix(created, 0) tick := func() time.Time { return clock } fx.srv.mailPushPass(ctx, tick) st, _ := fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", time.Time{}) if st.Pending != 1 || st.LastStatus != 503 || !strings.Contains(st.LastError, "503") || !st.LastFailedAt.Equal(clock) { t.Fatalf("after one failure: %+v — the failure should be dated when it happened, %s", st, clock.UTC()) } // Not due again until the first backoff has passed. clock = clock.Add(30 * time.Second) fx.srv.mailPushPass(ctx, tick) if hits != 1 { t.Fatalf("retried before its backoff: %d attempts", hits) } for _, wait := range mailPushBackoff { clock = clock.Add(wait) fx.srv.mailPushPass(ctx, tick) } if hits != len(mailPushBackoff)+1 { t.Errorf("attempts = %d, want %d", hits, len(mailPushBackoff)+1) } st, _ = fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", time.Time{}) if st.Abandoned != 1 || st.Pending != 0 { t.Errorf("after the schedule is spent: %+v", st) } clock = clock.Add(24 * time.Hour) fx.srv.mailPushPass(ctx, tick) if hits != len(mailPushBackoff)+1 { t.Error("an abandoned delivery was tried again") } // And the row is gone once the retention window has passed. clock = time.Unix(created, 0).Add(mailEventsRetention + time.Hour) fx.srv.mailPushPass(ctx, tick) if n := queued(t, fx, "jb-book"); n != 0 { t.Errorf("a queued payload outlived the retention window") } } // A redirect is where a delivery would become a POST of somebody's address // at a host the far end chooses. It is not followed. func TestADeliveryDoesNotFollowARedirect(t *testing.T) { elsewhere := notReached(t) defer elsewhere.Close() fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, elsewhere.URL+"/steal", http.StatusTemporaryRedirect) }) ctx := context.Background() fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", "m-ann", time.Now())), "sns-1") fx.srv.mailPushPass(ctx, time.Now) st, _ := fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", time.Time{}) if st.Delivered != 0 || !strings.Contains(st.LastError, "redirect") { t.Errorf("a redirect counted as delivered, or went unexplained: %+v", st) } } // Turning the push off stops what is already queued, not only what comes // next. func TestTurningThePushOffStopsTheQueue(t *testing.T) { hits := 0 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) { hits++ }) ctx := context.Background() fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", "m-ann", time.Now())), "sns-1") if _, err := fx.st.DeleteMailEventHook(ctx, release.OpsAccount, "jb-book", time.Now()); err != nil { t.Fatal(err) } fx.srv.mailPushPass(ctx, time.Now) if hits != 0 { t.Error("a delivery went out after the push was turned off") } } // The production target is the app's own platform host and nothing else. func TestThePushGoesOnlyToTheAppsOwnHost(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.AppsDomain = "OnCarlos.com" got, err := fx.srv.mailPushURL("beb", "jb-book", "/mail/events") if err != nil || got != "https://jb-book.beb.oncarlos.com/mail/events" { t.Errorf("target = %q, %v", got, err) } fx.srv.cfg.AppsDomain = "" if _, err := fx.srv.mailPushURL("beb", "jb-book", "/mail/events"); err == nil { t.Error("a deployment with no apps domain should have nowhere to send, not a guess") } } // One app's handler hanging must not be every app's problem. A pass sends to // apps side by side, and an app that does not answer loses its turn after // one timeout rather than spending the whole pass. func TestAHungAppDoesNotHoldUpAnotherApp(t *testing.T) { defer func(d time.Duration) { mailPushTimeout = d }(mailPushTimeout) mailPushTimeout = 300 * time.Millisecond var mu sync.Mutex hungHits, otherHits := 0, 0 unblock := make(chan struct{}) defer close(unblock) fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) { mu.Lock() if r.URL.Query().Get("app") == "jb-book" { hungHits++ mu.Unlock() select { case <-unblock: case <-time.After(5 * time.Second): } return } otherHits++ mu.Unlock() w.WriteHeader(http.StatusNoContent) }) ctx := context.Background() now := time.Now() for i := 0; i < 5; i++ { fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", "m-hung-"+string(rune('a'+i)), now)), "sns-hung-"+string(rune('a'+i))) } fx.srv.recordAndEnforceMail(ctx, sentBy("jb-site", bounceTo("bob@example.com", "m-site", now)), "sns-site") started := time.Now() if err := fx.srv.mailPushPass(ctx, time.Now); err != nil { t.Fatal(err) } if took := time.Since(started); took > 2*time.Second { t.Errorf("the pass took %v — a hung app spent it", took) } mu.Lock() defer mu.Unlock() if otherHits != 1 { t.Errorf("the other app was sent %d, want its one delivery", otherHits) } if hungHits != 1 { t.Errorf("the hung app was tried %d times in one pass, want 1 — its turn ends at the first silence", hungHits) } st, _ := fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", time.Time{}) if st.Pending != 5 { t.Errorf("the hung app's deliveries should all still be waiting: %+v", st) } } // A backlog waits behind itself. An app with far more due than a pass sends // does not push another app's one delivery out of the pass. func TestABacklogIsItsOwnAppsToWaitBehind(t *testing.T) { var mu sync.Mutex sent := map[string]int{} fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) { mu.Lock() sent[r.URL.Query().Get("app")]++ mu.Unlock() w.WriteHeader(http.StatusNoContent) }) ctx := context.Background() now := time.Now() for i := 0; i < 3*mailPushPerApp; i++ { id := "m-busy-" + strconv.Itoa(i) fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", id, now)), "sns-"+id) } fx.srv.recordAndEnforceMail(ctx, sentBy("jb-site", bounceTo("bob@example.com", "m-quiet", now)), "sns-quiet") if err := fx.srv.mailPushPass(ctx, time.Now); err != nil { t.Fatal(err) } mu.Lock() defer mu.Unlock() if sent["jb-site"] != 1 || sent["jb-book"] != mailPushPerApp { t.Errorf("sent %v, want jb-site 1 and jb-book %d", sent, mailPushPerApp) } } // Nothing is sent for an app that is not live, and a trashed app's queue is // given up on rather than kept waiting for a host it may no longer own. func TestNothingIsPushedForATrashedApp(t *testing.T) { hits := 0 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) { hits++ }) ctx := context.Background() fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", "m-ann", time.Now())), "sns-1") if err := fx.st.TrashAppRow(OpsAccountID, "jb-book", time.Now()); err != nil { t.Fatal(err) } fx.srv.mailPushPass(ctx, time.Now) if hits != 0 { t.Error("a delivery went to a trashed app's host") } st, _ := fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", time.Time{}) if st.Abandoned != 1 || st.Pending != 0 { t.Errorf("a trashed app's delivery should be given up on: %+v", st) } fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("bob@example.com", "m-bob", time.Now())), "sns-2") if n := queued(t, fx, "jb-book"); n != 1 { t.Errorf("a trashed app was queued something new") } } // The push is the app's, like its log: a rename takes it along, and a purge // leaves nothing for the next app of that name to inherit. func TestThePushFollowsTheAppAndNotItsName(t *testing.T) { fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) {}) ctx := context.Background() fx.srv.recordAndEnforceMail(ctx, sentBy("jb-site", bounceTo("ann@example.com", "m-ann", time.Now())), "sns-1") if err := fx.st.RenameAppIn(OpsAccountID, "jb-site", "jb-web"); err != nil { t.Fatal(err) } if hook, _ := fx.st.MailEventHookFor(ctx, release.OpsAccount, "jb-web"); hook == nil { t.Error("the renamed app lost its push") } if hook, _ := fx.st.MailEventHookFor(ctx, release.OpsAccount, "jb-site"); hook != nil { t.Error("the old name kept a push for whoever claims it next") } if n := queued(t, fx, "jb-web") + queued(t, fx, "jb-site"); n != 0 { t.Errorf("a rename carried %d queued deliveries whose bodies name the old app", n) } if err := fx.st.TrashAppRow(OpsAccountID, "jb-web", time.Now()); err != nil { t.Fatal(err) } if err := fx.st.PurgeAppRow(OpsAccountID, "jb-web"); err != nil { t.Fatal(err) } if hook, _ := fx.st.MailEventHookFor(ctx, release.OpsAccount, "jb-web"); hook != nil { t.Error("a purged app's push survived for its name") } if n := queued(t, fx, "jb-web") + queued(t, fx, "jb-site"); n != 0 { t.Errorf("a purged app's queue survived: %d rows", n) } } // An app that answers, but not with a 2xx, ends its turn at the first // failure: offering it the next row only holds a slot others wait on. func TestAFailingAppEndsItsTurnAtTheFirstFailure(t *testing.T) { var mu sync.Mutex hits := 0 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) { mu.Lock() hits++ mu.Unlock() w.WriteHeader(http.StatusInternalServerError) }) ctx := context.Background() now := time.Now() for i := 0; i < mailPushPerApp; i++ { id := "m-" + strconv.Itoa(i) fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", id, now)), "sns-"+id) } fx.srv.mailPushPass(ctx, time.Now) mu.Lock() defer mu.Unlock() if hits != 1 { t.Errorf("a failing app was offered %d rows in one pass, want 1", hits) } } // An app that accepts nothing for a day is suspended: what is waiting is // given up on, nothing new is queued, and turning the push on again lifts // it. Without this, a failing app's backlog grows for the thirty days the // queue is kept. func TestADayOfFailuresSuspendsThePush(t *testing.T) { hits := 0 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) { hits++ w.WriteHeader(http.StatusNotFound) }) ctx := context.Background() send := func(id string, at time.Time) { fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", id, at)), "sns-"+id) } clock := time.Now() tick := func() time.Time { return clock } for i := 0; i < 20; i++ { send("m-"+strconv.Itoa(i), clock) } fx.srv.mailPushPass(ctx, tick) hook, _ := fx.st.MailEventHookFor(ctx, release.OpsAccount, "jb-book") if hook.FailingSince.IsZero() || hook.Suspended() { t.Fatalf("after the first failure: %+v", hook) } // Most of a day of failing is not a day. clock = clock.Add(23 * time.Hour) fx.srv.mailPushPass(ctx, tick) if hook, _ = fx.st.MailEventHookFor(ctx, release.OpsAccount, "jb-book"); hook.Suspended() { t.Fatal("suspended before a day had passed") } clock = clock.Add(2 * time.Hour) fx.srv.mailPushPass(ctx, tick) hook, _ = fx.st.MailEventHookFor(ctx, release.OpsAccount, "jb-book") if !hook.Suspended() || !strings.Contains(hook.SuspendedWhy, "404") { t.Fatalf("a day of failures should suspend, saying what the app answered: %+v", hook) } st, _ := fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", hook.SetAt) if st.Pending != 0 || st.Abandoned != 20 { t.Errorf("a suspension should give up on the whole queue: %+v", st) } // Suspended: nothing new is queued, and a pass sends nothing. send("m-late", clock) if n := queued(t, fx, "jb-book"); n != 20 { t.Errorf("a suspended push queued more (%d rows)", n) } before := hits clock = clock.Add(time.Hour) fx.srv.mailPushPass(ctx, tick) if hits != before { t.Error("a suspended push was sent to") } // Turning it on again lifts the suspension and starts clean. if err := fx.st.SetMailEventHook(ctx, release.OpsAccount, "jb-book", "/mail/events", "bill@tito.io", clock); err != nil { t.Fatal(err) } hook, _ = fx.st.MailEventHookFor(ctx, release.OpsAccount, "jb-book") if hook.Suspended() || !hook.FailingSince.IsZero() { t.Errorf("turning it on again should lift the suspension: %+v", hook) } send("m-after", clock) if n := queued(t, fx, "jb-book"); n != 21 { t.Errorf("after turning it on again, events should queue: %d rows", n) } } // A run of failures belongs to a backlog, not to the app's history: an app // whose queue emptied — here by its only delivery running out of retries, // with nothing ever landing — is not suspended by its next miss days later. func TestAnOldFailureWithNothingWaitingDoesNotSuspend(t *testing.T) { fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }) ctx := context.Background() fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", "m-1", time.Now())), "sns-1") var created int64 fx.st.db.QueryRow(`SELECT created_at FROM mail_event_deliveries`).Scan(&created) clock := time.Unix(created, 0) tick := func() time.Time { return clock } fx.srv.mailPushPass(ctx, tick) for _, wait := range mailPushBackoff { clock = clock.Add(wait) fx.srv.mailPushPass(ctx, tick) } if st, _ := fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", time.Time{}); st.Pending != 0 || st.Abandoned != 1 { t.Fatalf("the one delivery should have run out of retries by now: %+v", st) } clock = clock.Add(3 * 24 * time.Hour) fx.srv.mailPushPass(ctx, tick) // nothing waiting: the run ends here fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("bob@example.com", "m-2", clock)), "sns-2") fx.st.db.Exec(`UPDATE mail_event_deliveries SET next_at = ?, created_at = ? WHERE notice = 'sns-2'`, clock.Unix(), clock.Unix()) fx.srv.mailPushPass(ctx, tick) if hook, _ := fx.st.MailEventHookFor(ctx, release.OpsAccount, "jb-book"); hook.Suspended() { t.Errorf("one miss after days of quiet suspended the push: %+v", hook) } } // A pass seeks each app's oldest due rows through an index rather than // sorting everything due: its cost follows the number of apps asking, not // the size of anyone's backlog. func TestAPassDoesNotSortAnAppsBacklog(t *testing.T) { fx := newConsoleFixture(t) rows, err := fx.st.db.Query(`EXPLAIN QUERY PLAN `+mailPushDueSQL, "beb", "jb-book", time.Now().Unix(), mailPushPerApp) if err != nil { t.Fatal(err) } defer rows.Close() var plan []string for rows.Next() { var id, parent, notused int var detail string rows.Scan(&id, &parent, ¬used, &detail) plan = append(plan, detail) } joined := strings.Join(plan, " | ") if !strings.Contains(joined, "mail_event_deliveries_app_due") || strings.Contains(joined, "TEMP B-TREE") { t.Errorf("the due read should seek the per-app index without sorting: %s", joined) } // And a large backlog costs a pass one app's worth of rows. sent := 0 fx2, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) { sent++; w.WriteHeader(http.StatusNoContent) }) ctx := context.Background() now := time.Now() for i := 0; i < 2000; i++ { if err := fx2.st.EnqueueMailEventDelivery(ctx, release.OpsAccount, "jb-book", "bulk-"+strconv.Itoa(i), []byte(`{}`), now); err != nil { t.Fatal(err) } } fx2.srv.mailPushPass(ctx, time.Now) if sent != mailPushPerApp { t.Errorf("one pass sent %d, want %d", sent, mailPushPerApp) } } // Turning the push off and on again, or lifting a suspension, starts the // status afresh: an old failure is not reported as the last attempt. func TestTheStatusForgetsFailuresFromBeforeThePushWasTurnedOn(t *testing.T) { fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) }) ctx := context.Background() fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", "m-1", time.Now())), "sns-1") fx.srv.mailPushPass(ctx, time.Now) if _, err := fx.st.DeleteMailEventHook(ctx, release.OpsAccount, "jb-book", time.Now()); err != nil { t.Fatal(err) } later := time.Now().Add(time.Minute) if err := fx.st.SetMailEventHook(ctx, release.OpsAccount, "jb-book", "/mail/events", "bill@tito.io", later); err != nil { t.Fatal(err) } st, _ := fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", later) if st.LastError != "" || st.LastStatus != 0 { t.Errorf("a failure from before the push was turned on again was reported: %+v", st) } var why, lastErr string fx.st.db.QueryRow(`SELECT abandoned_why, last_error FROM mail_event_deliveries`).Scan(&why, &lastErr) if why != "push turned off" || !strings.Contains(lastErr, "503") { t.Errorf("giving up should record why without overwriting what the app answered: why=%q last_error=%q", why, lastErr) } } // A move to another account drops the queue, like a rename: the bodies name // the old account. func TestAMoveDropsTheQueue(t *testing.T) { fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) {}) ctx := context.Background() fx.srv.recordAndEnforceMail(ctx, sentBy("jb-site", bounceTo("ann@example.com", "m-1", time.Now())), "sns-1") if err := fx.st.CreateAccount(99, "dest", "Destination", "bill@tito.io", time.Now()); err != nil { t.Fatal(err) } if err := fx.st.MoveApp("jb-site", OpsAccountID, 99); err != nil { t.Fatal(err) } var n int fx.st.db.QueryRow(`SELECT count(*) FROM mail_event_deliveries WHERE app = 'jb-site'`).Scan(&n) if n != 0 { t.Errorf("a move carried %d queued deliveries naming the old account", n) } if hook, _ := fx.st.MailEventHookFor(ctx, "dest", "jb-site"); hook == nil { t.Error("the push itself should move with the app") } } // A prune that fails is not stamped, and does not cost the pass. func TestAFailedQueuePruneIsTriedAgain(t *testing.T) { fx := newConsoleFixture(t) ctx, cancel := context.WithCancel(context.Background()) cancel() if err := fx.st.pruneMailEventDeliveries(ctx, time.Now()); err == nil { t.Fatal("a prune with a cancelled context should fail") } if got := fx.st.mailPushPruned.Load(); got != 0 { t.Errorf("the stamp was left at %d after a failed prune", got) } }