//go:build integration package inttest import ( "context" "encoding/csv" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "slices" "strings" "sync" "testing" "time" "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/account" "scrabble/backend/internal/mynalog" "scrabble/backend/internal/mynalogsync" "scrabble/backend/internal/payments" "scrabble/backend/internal/server" ) // taxStub stands in for the professional-income tax cabinet. It records what it was asked and, by // default, behaves like the real service: it mints a receipt id and remembers the income under the // exact service name it was filed with, which is what the recovery probe searches by. type taxStub struct { srv *httptest.Server mu sync.Mutex calls []string filed map[string]string // service name -> receipt id annulled map[string]bool // receipt id -> annulled seq int // onIncome overrides the default success. Returning a non-zero status makes the stub answer // that instead; filing is the callback's own business, which is how a "the call failed but the // receipt exists" case is set up. onIncome func(s *taxStub, name string) (status int, body string) } // newTaxStub starts a stub cabinet and stops it when the test ends. func newTaxStub(t *testing.T) *taxStub { t.Helper() st := &taxStub{filed: map[string]string{}, annulled: map[string]bool{}} st.srv = httptest.NewServer(http.HandlerFunc(st.serve)) t.Cleanup(st.srv.Close) return st } // serve routes one stub call. func (s *taxStub) serve(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) s.mu.Lock() s.calls = append(s.calls, r.URL.Path) s.mu.Unlock() w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/api/v1/auth/lkfl", "/api/v1/auth/token": _, _ = io.WriteString(w, `{"token":"access","refreshToken":"refresh","profile":{"inn":"770000000000"}}`) case "/api/v1/income": s.serveIncome(w, body) case "/api/v1/cancel": s.serveCancel(w, body) case "/api/v1/incomes": s.serveList(w) default: w.WriteHeader(http.StatusNotFound) } } // serveIncome registers an income, or defers to the test's override. func (s *taxStub) serveIncome(w http.ResponseWriter, body []byte) { var req struct { Services []struct { Name string `json:"name"` } `json:"services"` } _ = json.Unmarshal(body, &req) name := "" if len(req.Services) > 0 { name = req.Services[0].Name } if s.onIncome != nil { if status, out := s.onIncome(s, name); status != 0 { w.WriteHeader(status) _, _ = io.WriteString(w, out) return } } _, _ = io.WriteString(w, fmt.Sprintf(`{"approvedReceiptUuid":%q}`, s.file(name))) } // file records an income under name and returns the receipt id the stub assigned. func (s *taxStub) file(name string) string { s.mu.Lock() defer s.mu.Unlock() s.seq++ id := fmt.Sprintf("receipt-%d", s.seq) s.filed[name] = id return id } // serveCancel annuls a receipt. func (s *taxStub) serveCancel(w http.ResponseWriter, body []byte) { var req struct { ReceiptUUID string `json:"receiptUuid"` } _ = json.Unmarshal(body, &req) s.mu.Lock() s.annulled[req.ReceiptUUID] = true s.mu.Unlock() _, _ = io.WriteString(w, `{}`) } // serveList answers the income list the recovery probe searches, omitting annulled receipts the way // the real service marks them. func (s *taxStub) serveList(w http.ResponseWriter) { s.mu.Lock() defer s.mu.Unlock() var items []string for name, id := range s.filed { cancellation := "null" if s.annulled[id] { cancellation = `{"comment":"Возврат средств"}` } items = append(items, fmt.Sprintf(`{"name":%q,"approvedReceiptUuid":%q,"cancellationInfo":%s}`, name, id, cancellation)) } _, _ = io.WriteString(w, `{"content":[`+strings.Join(items, ",")+`]}`) } // hits reports how many times a path was called. func (s *taxStub) hits(path string) int { s.mu.Lock() defer s.mu.Unlock() n := 0 for _, c := range s.calls { if c == path { n++ } } return n } // receiptFor returns the receipt id the stub assigned to a service name. func (s *taxStub) receiptFor(name string) (string, bool) { s.mu.Lock() defer s.mu.Unlock() id, ok := s.filed[name] return id, ok } // isAnnulled reports whether a receipt has been annulled. func (s *taxStub) isAnnulled(id string) bool { s.mu.Lock() defer s.mu.Unlock() return s.annulled[id] } // recordingAlerter captures operator alerts instead of emailing them. type recordingAlerter struct { mu sync.Mutex kinds []string } func (a *recordingAlerter) Alert(_ context.Context, kind, _, _ string) error { a.mu.Lock() defer a.mu.Unlock() a.kinds = append(a.kinds, kind) return nil } // saw reports whether an alert of the given kind was raised. func (a *recordingAlerter) saw(kind string) bool { a.mu.Lock() defer a.mu.Unlock() for _, k := range a.kinds { if k == kind { return true } } return false } // taxKey is the encryption key the tests seal the stored refresh token with. const taxKey = "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=" // taxServer wires a console server with the tax export pointed at the stub. func taxServer(t *testing.T, st *taxStub) (*server.Server, *payments.Service, *mynalogsync.Exporter, *recordingAlerter) { t.Helper() pay := newPaymentsService() key, err := mynalog.ParseKey(taxKey) if err != nil { t.Fatalf("key: %v", err) } alerts := &recordingAlerter{} exp := mynalogsync.New(pay, mynalogsync.Config{ Key: key, BaseURL: st.srv.URL, Location: time.FixedZone("MSK", 3*60*60), Pace: time.Millisecond, // production paces at 2s; a test must not }, alerts, zap.NewNop()) if exp == nil { t.Fatal("exporter not built") } srv := server.New(":0", server.Deps{ Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: newGameService(), Registry: testRegistry, DictDir: dictDir(), Payments: pay, MyNalog: exp, }) drainTaxQueue(t, pay) return srv, pay, exp, alerts } // drainTaxQueue rules out every rouble income already in the shared test database, so a test starts // from an empty queue and can assert on counts. The database is shared across the package, and // other payment tests fund YooKassa orders that this export would otherwise pick up. func drainTaxQueue(t *testing.T, pay *payments.Service) { t.Helper() ctx := context.Background() for { queue, err := pay.MyNalogQueue(ctx, 500) if err != nil { t.Fatalf("drain queue: %v", err) } pending := append(append([]payments.MyNalogIncome{}, queue.Incomes...), queue.NotRequired...) pending = append(pending, queue.Attention...) if len(pending) == 0 { return } for _, in := range pending { if err := pay.MarkMyNalogNotRequired(ctx, in, "seeded by another test"); err != nil { t.Fatalf("drain row: %v", err) } } } } // fundTaxableOrder funds one YooKassa rouble order and returns the account and the order id. func fundTaxableOrder(t *testing.T, pay *payments.Service, chips int, minor int64) (uuid.UUID, uuid.UUID) { t.Helper() ctx := context.Background() acc := provisionAccount(t) prod := seedPackProduct(t, chips, methodPrice{method: "direct", currency: "RUB", amount: minor}) res, err := pay.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "yookassa") if err != nil { t.Fatalf("order: %v", err) } paid, err := payments.MoneyFromMinor(minor, payments.CurrencyRUB) if err != nil { t.Fatalf("money: %v", err) } if _, err := pay.Fund(ctx, res.OrderID, "yookassa", "pay-"+res.OrderID.String(), paid); err != nil { t.Fatalf("fund: %v", err) } return acc, res.OrderID } // signInTax signs the export into the stub cabinet through the console. func signInTax(t *testing.T, srv *server.Server) { t.Helper() code, body := consoleDo(srv.Handler(), http.MethodPost, "http://admin.test/_gm/mynalog/login", "login=770000000000&password=secret", "http://admin.test") if code != http.StatusOK || !strings.Contains(body, "Signed in") { t.Fatalf("sign in = %d: %s", code, body) } } // onlyIncome returns the single owed income, failing when the queue does not hold exactly one. func onlyIncome(t *testing.T, pay *payments.Service) payments.MyNalogIncome { t.Helper() queue, err := pay.MyNalogQueue(context.Background(), 500) if err != nil { t.Fatalf("queue: %v", err) } if len(queue.Incomes) != 1 { t.Fatalf("owed incomes = %d, want 1", len(queue.Incomes)) } return queue.Incomes[0] } // statusOf reads the export status recorded for one income. func statusOf(t *testing.T, pay *payments.Service, ledgerID uuid.UUID) payments.MyNalogIncome { t.Helper() in, ok, err := pay.MyNalogIncomeAt(context.Background(), ledgerID) if err != nil || !ok { t.Fatalf("income %s: ok=%v err=%v", ledgerID, ok, err) } return in } // TestMyNalogFilesIncomeAndAnnulsOnRefund walks the happy path end to end: a rouble purchase is // filed with the tax service under a name carrying its order marker, and a later refund annuls that // receipt without any further human involvement. func TestMyNalogFilesIncomeAndAnnulsOnRefund(t *testing.T) { ctx := context.Background() st := newTaxStub(t) srv, pay, exp, _ := taxServer(t, st) signInTax(t, srv) _, orderID := fundTaxableOrder(t, pay, 100, 14900) owed := onlyIncome(t, pay) name := exp.ReceiptName(owed) // The marker is the TAIL of the order id: a UUIDv7's leading digits are a millisecond clock and // would be shared by every order placed within about a minute, which would make the recovery // probe unable to tell two purchases apart. marker := orderID.String()[len(orderID.String())-8:] if !strings.Contains(name, marker) { t.Fatalf("service name %q does not carry the order marker %q — the recovery probe depends on it", name, marker) } sum, err := exp.RunBatch(ctx, time.Minute) if err != nil { t.Fatalf("run: %v", err) } if sum.Registered != 1 { t.Fatalf("registered = %d, want 1 (%+v)", sum.Registered, sum) } receipt, ok := st.receiptFor(name) if !ok { t.Fatalf("the stub cabinet has no receipt for %q", name) } if got := statusOf(t, pay, owed.LedgerID); got.Status != payments.MyNalogSent || got.ReceiptUUID != receipt { t.Fatalf("status = %q receipt = %q, want sent/%s", got.Status, got.ReceiptUUID, receipt) } // Refund the order: the annulment is owed automatically. if _, err := pay.RefundOrderFull(ctx, orderID); err != nil { t.Fatalf("refund: %v", err) } sum, err = exp.RunBatch(ctx, time.Minute) if err != nil { t.Fatalf("run after refund: %v", err) } if sum.Cancelled != 1 { t.Fatalf("cancelled = %d, want 1 (%+v)", sum.Cancelled, sum) } if !st.isAnnulled(receipt) { t.Fatal("the receipt was not annulled at the tax service") } } // TestMyNalogProbeRecoversAnAmbiguousCall covers the failure this whole design exists for: the // registration call has no idempotency key, so a receipt can exist even though the call reported an // error. The probe must find it and record it, rather than the income being filed a second time. func TestMyNalogProbeRecoversAnAmbiguousCall(t *testing.T) { ctx := context.Background() st := newTaxStub(t) // File the income, then answer as if the service fell over. st.onIncome = func(s *taxStub, name string) (int, string) { s.file(name) return http.StatusBadGateway, `{"message":"gateway"}` } srv, pay, exp, alerts := taxServer(t, st) signInTax(t, srv) fundTaxableOrder(t, pay, 100, 14900) owed := onlyIncome(t, pay) sum, err := exp.RunBatch(ctx, time.Minute) if err != nil { t.Fatalf("run: %v", err) } if sum.Registered != 1 || sum.Unknown != 0 { t.Fatalf("registered = %d unknown = %d, want the probe to recover it (%+v)", sum.Registered, sum.Unknown, sum) } if got := statusOf(t, pay, owed.LedgerID); got.Status != payments.MyNalogSent { t.Fatalf("status = %q, want sent", got.Status) } if st.hits("/api/v1/incomes") == 0 { t.Fatal("the probe was never issued") } if alerts.saw("unknown") { t.Fatal("a recovered call must not alert the operator") } } // TestMyNalogUnresolvedOutcomeHaltsTheQueue asserts the rule that keeps income from being filed // twice: when the call fails and the probe finds nothing, the income is left unresolved and NOTHING // further is filed until a human has looked at it. func TestMyNalogUnresolvedOutcomeHaltsTheQueue(t *testing.T) { ctx := context.Background() st := newTaxStub(t) st.onIncome = func(*taxStub, string) (int, string) { return http.StatusBadGateway, `{}` } srv, pay, exp, alerts := taxServer(t, st) signInTax(t, srv) fundTaxableOrder(t, pay, 100, 14900) first := onlyIncome(t, pay) sum, err := exp.RunBatch(ctx, time.Minute) if err != nil { t.Fatalf("run: %v", err) } if sum.Unknown != 1 || sum.Registered != 0 { t.Fatalf("unknown = %d registered = %d, want one unresolved (%+v)", sum.Unknown, sum.Registered, sum) } if got := statusOf(t, pay, first.LedgerID); got.Status != payments.MyNalogUnknown { t.Fatalf("status = %q, want unknown", got.Status) } if !alerts.saw("unknown") { t.Fatal("an unresolved outcome must alert the operator") } // A second, perfectly fine purchase must NOT be filed while the first is unresolved. st.onIncome = nil fundTaxableOrder(t, pay, 50, 9900) sum, err = exp.RunBatch(ctx, time.Minute) if err != nil { t.Fatalf("second run: %v", err) } if sum.Registered != 0 { t.Fatalf("registered = %d while an outcome is unresolved, want 0", sum.Registered) } if st.hits("/api/v1/income") != 1 { t.Fatalf("income calls = %d, want no new filing attempt", st.hits("/api/v1/income")) } // The operator asserts nothing was created; the income goes back into the queue and files. if err := exp.Requeue(ctx, first.LedgerID); err != nil { t.Fatalf("requeue: %v", err) } sum, err = exp.RunBatch(ctx, time.Minute) if err != nil { t.Fatalf("run after requeue: %v", err) } if sum.Registered != 2 { t.Fatalf("registered = %d after requeue, want both (%+v)", sum.Registered, sum) } } // TestMyNalogPausesAfterRepeatedRejections asserts the circuit breaker: an API that rejects // everything — the shape a format change takes — must take the rail out of service rather than // producing thousands of futile requests. func TestMyNalogPausesAfterRepeatedRejections(t *testing.T) { ctx := context.Background() st := newTaxStub(t) st.onIncome = func(*taxStub, string) (int, string) { return http.StatusBadRequest, `{"message":"unknown field"}` } srv, pay, exp, alerts := taxServer(t, st) signInTax(t, srv) for i := 0; i < 4; i++ { fundTaxableOrder(t, pay, 10, 1000) } sum, err := exp.RunBatch(ctx, time.Minute) if err != nil { t.Fatalf("run: %v", err) } if !sum.Paused { t.Fatalf("the rail did not pause after repeated rejections (%+v)", sum) } if st.hits("/api/v1/income") != 3 { t.Fatalf("income calls = %d, want the run to stop after 3", st.hits("/api/v1/income")) } if !alerts.saw("paused") { t.Fatal("a self-imposed pause must alert the operator") } // While paused, a run does nothing at all. before := st.hits("/api/v1/income") sum, err = exp.RunBatch(ctx, time.Minute) if err != nil { t.Fatalf("run while paused: %v", err) } if !sum.Paused || st.hits("/api/v1/income") != before { t.Fatalf("a paused rail still called the service (%+v)", sum) } // Resuming through the console lets it run again. st.onIncome = nil if code, _ := consoleDo(srv.Handler(), http.MethodPost, "http://admin.test/_gm/mynalog/resume", "", "http://admin.test"); code != http.StatusOK { t.Fatalf("resume = %d", code) } sum, err = exp.RunBatch(ctx, time.Minute) if err != nil { t.Fatalf("run after resume: %v", err) } if sum.Registered != 4 { t.Fatalf("registered = %d after resume, want 4 (%+v)", sum.Registered, sum) } } // TestMyNalogSkipsIncomeRefundedBeforeFiling asserts that a purchase the buyer got back before any // receipt was issued is ruled out locally: nothing is filed, nothing is annulled, and the tax // service is not contacted at all. func TestMyNalogSkipsIncomeRefundedBeforeFiling(t *testing.T) { ctx := context.Background() st := newTaxStub(t) srv, pay, exp, _ := taxServer(t, st) signInTax(t, srv) callsAfterSignIn := len(st.calls) _, orderID := fundTaxableOrder(t, pay, 100, 14900) if _, err := pay.RefundOrderFull(ctx, orderID); err != nil { t.Fatalf("refund: %v", err) } sum, err := exp.RunBatch(ctx, time.Minute) if err != nil { t.Fatalf("run: %v", err) } if sum.NotRequired != 1 || sum.Registered != 0 { t.Fatalf("not required = %d registered = %d, want it ruled out (%+v)", sum.NotRequired, sum.Registered, sum) } if len(st.calls) != callsAfterSignIn { t.Fatalf("the tax service was contacted for an income that needed nothing: %v", st.calls[callsAfterSignIn:]) } } // TestMyNalogIdleRunContactsNobody asserts the property that keeps an idle installation invisible to // the tax service: with an empty queue a run does not even authenticate. func TestMyNalogIdleRunContactsNobody(t *testing.T) { st := newTaxStub(t) srv, _, exp, _ := taxServer(t, st) signInTax(t, srv) before := len(st.calls) sum, err := exp.RunBatch(context.Background(), time.Minute) if err != nil { t.Fatalf("run: %v", err) } if sum.Registered != 0 || sum.Cancelled != 0 { t.Fatalf("an idle run did something: %+v", sum) } if len(st.calls) != before { t.Fatalf("an idle run issued %v", st.calls[before:]) } } // TestMyNalogManualEntryStaysAnnullable asserts why the console asks for the receipt number rather // than a simple "done" tick: an income filed by hand must still have its receipt annulled // automatically when the money goes back. func TestMyNalogManualEntryStaysAnnullable(t *testing.T) { ctx := context.Background() st := newTaxStub(t) srv, pay, exp, _ := taxServer(t, st) signInTax(t, srv) _, orderID := fundTaxableOrder(t, pay, 100, 14900) owed := onlyIncome(t, pay) // The operator files it in the cabinet by hand and types the number back in. form := "ledger_id=" + owed.LedgerID.String() + "&receipt_uuid=hand-1" if code, body := consoleDo(srv.Handler(), http.MethodPost, "http://admin.test/_gm/mynalog/manual", form, "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Recorded") { t.Fatalf("manual entry = %d: %s", code, body) } if got := statusOf(t, pay, owed.LedgerID); got.Status != payments.MyNalogSent || got.ReceiptUUID != "hand-1" { t.Fatalf("status = %q receipt = %q, want sent/hand-1", got.Status, got.ReceiptUUID) } if _, err := pay.RefundOrderFull(ctx, orderID); err != nil { t.Fatalf("refund: %v", err) } sum, err := exp.RunBatch(ctx, time.Minute) if err != nil { t.Fatalf("run: %v", err) } if sum.Cancelled != 1 || !st.isAnnulled("hand-1") { t.Fatalf("a hand-filed receipt was not annulled (%+v)", sum) } } // TestMyNalogConsoleExportListsWhatWouldBeSent asserts the manual fallback carries exactly the // fields the tax cabinet's own form asks for, plus the ledger id needed to enter the receipt number // back afterwards. func TestMyNalogConsoleExportListsWhatWouldBeSent(t *testing.T) { st := newTaxStub(t) srv, pay, exp, _ := taxServer(t, st) fundTaxableOrder(t, pay, 100, 14900) owed := onlyIncome(t, pay) code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/mynalog.csv", "", "") if code != http.StatusOK { t.Fatalf("export = %d", code) } // Parsed rather than substring-matched: the service name contains quotes, so the CSV encoding of // it is not the string an operator has to type. rows, err := csv.NewReader(strings.NewReader(body)).ReadAll() if err != nil { t.Fatalf("parse CSV: %v\n%s", err, body) } if len(rows) != 2 { t.Fatalf("CSV rows = %d, want a header and one income\n%s", len(rows), body) } wantHeader := []string{"operation_time", "service_name", "amount", "currency", "status", "ledger_id"} if !slices.Equal(rows[0], wantHeader) { t.Fatalf("header = %v, want %v", rows[0], wantHeader) } got := rows[1] if got[1] != exp.ReceiptName(owed) { t.Errorf("service name = %q, want exactly what would be sent (%q)", got[1], exp.ReceiptName(owed)) } if got[2] != "149.00" || got[3] != "RUB" { t.Errorf("amount = %q %q, want 149.00 RUB", got[2], got[3]) } if got[5] != owed.LedgerID.String() { t.Errorf("ledger id = %q, want %s — without it the receipt number cannot be entered back", got[5], owed.LedgerID) } } // TestMyNalogConsoleRequiresSameOrigin asserts the export actions carry the console's only CSRF // defence, since a forged sign-in would hand an attacker's credentials to the rail. func TestMyNalogConsoleRequiresSameOrigin(t *testing.T) { st := newTaxStub(t) srv, _, _, _ := taxServer(t, st) for _, path := range []string{"/_gm/mynalog/login", "/_gm/mynalog/run", "/_gm/mynalog/auto"} { if code, _ := consoleDo(srv.Handler(), http.MethodPost, "http://admin.test"+path, "", ""); code != http.StatusForbidden { t.Errorf("%s without an origin = %d, want 403", path, code) } } } // TestMyNalogBuyerMails asserts the three letters a buyer gets and that none of them is sent twice. func TestMyNalogBuyerMails(t *testing.T) { ctx := context.Background() st := newTaxStub(t) srv, pay, exp, _ := taxServer(t, st) signInTax(t, srv) accounts := account.NewStore(testDB) acc, orderID := fundTaxableOrder(t, pay, 100, 14900) // Derived from the account so the address is unique in the shared test database: identities are // unique on (kind, external_id), and a fixed literal collides with another suite's fixture. addr := "mynalog-" + acc.String() + "@example.test" if err := accounts.AttachIdentity(ctx, acc, account.KindEmail, addr, true); err != nil { t.Fatalf("attach email: %v", err) } // The crediting path records the outbox event the purchase letter rides. payload, _ := json.Marshal(map[string]any{"chips": 100, "source": "direct"}) if err := pay.RecordPaymentEvent(ctx, acc, &orderID, "succeeded", payload); err != nil { t.Fatalf("record event: %v", err) } box := &recordingMailer{} mailer := mynalogsync.NewMailer(pay, accounts, box, "no-reply@example.test", st.srv.URL, time.FixedZone("MSK", 3*60*60), zap.NewNop()) if mailer == nil { t.Fatal("mailer not built") } // 1. The purchase confirmation, which must say plainly that it is not the fiscal receipt. mailer.Tick(ctx) first := box.to(addr) if len(first) != 1 { t.Fatalf("purchase letters = %d, want 1", len(first)) } if !strings.Contains(first[0].Text, "не чек") { t.Errorf("the purchase letter does not say it is not the receipt:\n%s", first[0].Text) } // 2. The receipt letter, once the income is filed, carrying the printable link. if _, err := exp.RunBatch(ctx, time.Minute); err != nil { t.Fatalf("run: %v", err) } mailer.Tick(ctx) if got := len(box.to(addr)); got != 2 { t.Fatalf("letters after filing = %d, want 2", got) } if !strings.Contains(box.to(addr)[1].Text, "/api/v1/receipt/770000000000/") { t.Errorf("the receipt letter carries no receipt link:\n%s", box.to(addr)[1].Text) } // 3. The annulment letter after a refund. if _, err := pay.RefundOrderFull(ctx, orderID); err != nil { t.Fatalf("refund: %v", err) } if _, err := exp.RunBatch(ctx, time.Minute); err != nil { t.Fatalf("run after refund: %v", err) } mailer.Tick(ctx) if got := len(box.to(addr)); got != 3 { t.Fatalf("letters after the refund = %d, want 3", got) } if !strings.Contains(box.to(addr)[2].Text, "аннулирован") { t.Errorf("the third letter is not the annulment notice:\n%s", box.to(addr)[2].Text) } // Draining again must send nothing: every letter is decided exactly once. mailer.Tick(ctx) if got := len(box.to(addr)); got != 3 { t.Fatalf("letters after a second drain = %d, want 3 — a letter was repeated", got) } } // recordingMailer captures outgoing mail instead of relaying it. type recordingMailer struct { mu sync.Mutex sent []account.Message } func (m *recordingMailer) Send(_ context.Context, msg account.Message) error { m.mu.Lock() defer m.mu.Unlock() m.sent = append(m.sent, msg) return nil } // to returns the messages addressed to one recipient, in order. func (m *recordingMailer) to(addr string) []account.Message { m.mu.Lock() defer m.mu.Unlock() var out []account.Message for _, msg := range m.sent { if msg.To == addr { out = append(out, msg) } } return out }