Files
scrabble-game/backend/internal/inttest/mynalog_test.go
T
Ilia Denisov 69bddaeb9a
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
fix(admin): shorten the receipt letter, show the account id
The receipt letter repeated what the receipt itself states. It now carries the
amount, the moment of payment with its offset, and the link — the receipt is
the document, and it opens without authentication, so there is nothing for the
two to disagree about. Subject reworded to name what it is.

The ledger and tax-export tables labelled the account link "card", meaning the
user card. On pages about payments that reads as a bank card, which is exactly
how it was misread. Both now show the first eight characters of the account id,
so the column carries data — two rows from the same buyer are visible at a
glance — while the link still holds the whole id.
2026-07-28 16:44:44 +02:00

743 lines
25 KiB
Go

//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)
}
// The catalog title already names the pack size, so appending it again read as
// "50 фишек — 50 фишек". The line is the title verbatim, then the price, and nothing else.
if !strings.Contains(first[0].Text, "Покупка совершена: test pack, 149.00 ₽.") {
t.Errorf("the purchase line is not the catalog title plus the price:\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)
}
receipt := box.to(addr)[1]
if receipt.Subject != "Эрудит — Ваш чек покупки" {
t.Errorf("receipt letter subject = %q", receipt.Subject)
}
if !strings.Contains(receipt.Text, "/api/v1/receipt/770000000000/") {
t.Errorf("the receipt letter carries no receipt link:\n%s", receipt.Text)
}
// The amount carries its time-zone offset, because that offset is what decides the tax period
// the receipt belongs to and the buyer should see the same moment the receipt states.
if !strings.Contains(receipt.Text, "Покупка на сумму: 149.00 ₽ от ") ||
!strings.Contains(receipt.Text, "+03:00") {
t.Errorf("the receipt letter's amount line is wrong:\n%s", receipt.Text)
}
// Nothing beyond the amount, the moment and the link: the receipt is the document.
if strings.Contains(receipt.Text, "test pack") {
t.Errorf("the receipt letter repeats the catalog title; the receipt itself carries it:\n%s", receipt.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
}