Files
scrabble-game/backend/internal/inttest/mynalog_test.go
T
Ilia Denisov 4d4452596d
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 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
fix(payments): stop doubling the pack size in buyer mail
Catalog titles already name the pack ("50 фишек"), so appending the chip count
produced "50 фишек — 50 фишек, 79.00 ₽". The letter now carries the title
exactly as the storefront shows it, then the price.

The tax receipt is unaffected: it states the quantity in the wording the tax
service wants, which is a separate surface with separate requirements.
2026-07-28 16:14:53 +02:00

729 lines
24 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)
}
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
}