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
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.
729 lines
24 KiB
Go
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
|
|
}
|