Files
Bridge-and-Join-s/internal/nsdadapter/mock/sender.go
fontvielle 5fa6ea6ab1 feat(robot): эмулятор робота-автотеста НРД + help-страница + REPORT.md
Реализован внутренний робот-эмулятор в internal/nsdadapter/mock/robot.go.
Источник правил: DOC/instruktsiya-po-testirovaniyu-s-robotom.pdf (от
12.05.2026). Когда mock.Sender видит Header.ReceiverCode == MC0012500000
и DocumentSeries в {1111, 2001, 2002, 3333} — формирует Decision по
выбранному сценарию вместо default-логики.

Сценарии:
- 1111 «Ответ с отказом»: все бумаги Rejection, код ошибки берётся из
  последних 2 символов DocumentNumber (01..09 → M2M01..M2M09)
- 2001 «Принять все бумаги»: все Confirmation; i-й символ DocumentNumber
  = номер депозитария-получателя для i-й секции (1/2)
- 2002 «Принять частично»: 0 = отклонить с M2M05, иначе номер депозитария
- 3333 «Выступить принимающей стороной»: пока только первое сообщение
  (отказ M2M05). Встречный M2MTransferRequest от робота — TODO
  (требует приёмной стороны bj-server)

Тестовые наборы депозитариев (ИНН 7702165310, depcode MC0012500000,
счёт HL2603250011, разделы 31MC0012500000F00 и 36MC0012500000F00)
зашиты в robotDepositary — соответствуют таблице из инструкции.

Help-страница /admin/help/robot с полным описанием: коды робота,
сценарии, управление через DocumentNumber, тестовые данные, коды ошибок
M2M01-M2M09, как переключиться на реальный TEST3 после получения ИШ.

REPORT.md — сводный отчёт для руководства о ходе работ: ~65% общей
готовности системы, ~80% готовности к интеграционному тесту с роботом
(остальное — внешние блокеры: дистрибутив ИШ, сертификат УЦ МБ).
Расписан план первичного тестирования после получения ИШ — 2-3 недели
до продакшена.

.gitignore: исключены DOC/*.pdf.bak (бэкапы doc-watcher'a).
2026-05-14 16:53:52 +03:00

249 lines
8.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package mock — заглушка NSDSender для локальных стендов без реального
// Интеграционного шлюза НРД. Возвращает синтетические M2MTransferResponse
// (синхронно, сразу) и эмитит M2MTransferDecision (асинхронно, через
// настраиваемую задержку) для каждой отправленной заявки.
//
// Подходит для:
// - сквозного дев-теста (ЛК → m2m-core → mock → callback в ЛК);
// - демонстраций «увидеть как оно работает» без подключения к НРД;
// - юнит-тестов компонентов выше уровня транспорта.
package mock
import (
"context"
"errors"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
)
// DecisionOutcome — что должен вернуть mock после задержки.
type DecisionOutcome int
const (
OutcomeConfirm DecisionOutcome = iota
OutcomeReject
OutcomeTimeout
)
// Config — настройки mock-сендера.
type Config struct {
// DecisionDelay — задержка между Send и эмиссией Decision в канал.
DecisionDelay time.Duration
// DefaultOutcome — каким будет Decision, если не задан per-Request override.
DefaultOutcome DecisionOutcome
// RejectionCode — какой код отказа возвращать в OutcomeReject (6 chars max).
RejectionCode string
// SenderCode/ReceiverCode для эмитированной Decision Header (берётся из
// исходного Request обменом местами).
NSDSenderCode m2m.DeponentCode
}
// DefaultConfig — разумные дефолты: подтверждение через 3 секунды.
func DefaultConfig() Config {
return Config{
DecisionDelay: 3 * time.Second,
DefaultOutcome: OutcomeConfirm,
RejectionCode: "07",
}
}
// Sender — mock реализация m2mcore.NSDSender.
type Sender struct {
cfg Config
decisions chan *m2m.M2MTransferDecision
outcomes sync.Map // map[m2m.UUID]DecisionOutcome — override per GUID
mu sync.Mutex
stats stats
lifeCtx context.Context // независимый от HTTP-запроса контекст для эмиссии Decision
lifeCancel context.CancelFunc
}
type stats struct {
Sent uint64
Confirmed uint64
Rejected uint64
TimedOut uint64
}
// NewSender создаёт mock с указанной конфигурацией.
func NewSender(cfg Config) *Sender {
ctx, cancel := context.WithCancel(context.Background())
return &Sender{
cfg: cfg,
decisions: make(chan *m2m.M2MTransferDecision, 64),
lifeCtx: ctx,
lifeCancel: cancel,
}
}
// Stop отменяет внутренний контекст mock'а, останавливая все запущенные
// emit-горутины.
func (s *Sender) Stop() { s.lifeCancel() }
// SetOutcome задаёт исход для конкретной заявки по GUID (вызывается до Send).
func (s *Sender) SetOutcome(guid m2m.UUID, out DecisionOutcome) {
s.outcomes.Store(guid, out)
}
// Decisions — канал эмитированных от имени принимающей стороны Decision'ов.
// Подписаться, считать в горутине, передавать в m2mcore.Deal.ReceiveDecision.
func (s *Sender) Decisions() <-chan *m2m.M2MTransferDecision { return s.decisions }
// Stats — текущие счётчики (для UI).
func (s *Sender) Stats() (sent, confirmed, rejected, timedOut uint64) {
s.mu.Lock()
defer s.mu.Unlock()
return s.stats.Sent, s.stats.Confirmed, s.stats.Rejected, s.stats.TimedOut
}
// Send имитирует отправку в НРД. Возвращает синтетический Response
// с StatusCode=INFO. Через DecisionDelay в канал прилетает Decision.
func (s *Sender) Send(ctx context.Context, req *m2m.M2MTransferRequest) (*m2m.M2MTransferResponse, error) {
if req == nil {
return nil, errors.New("nsdadapter/mock: Send: req=nil")
}
if err := req.Validate(); err != nil {
return nil, err
}
s.mu.Lock()
s.stats.Sent++
s.mu.Unlock()
// Синтетический Response: принимаем заявку, по каждой ЦБ — INFO 01.
resp := &m2m.M2MTransferResponse{
GUID: req.Header.GUID,
StatusCode: m2m.StatusInfo,
}
for _, sec := range req.Data.TransferredSecurities.Securities {
refID := sec.ReferenceID
resp.Responses = append(resp.Responses, m2m.Response{
ReferenceID: &refID,
Code: "01",
Text: "Запрос на перевод принят НРД и направлен принимающей стороне (mock).",
})
}
// Эмитим Decision в отдельной горутине. Используем lifeCtx, чтобы
// HTTP-таймауты вызывающего не прерывали эмиссию.
outcome := s.cfg.DefaultOutcome
if v, ok := s.outcomes.Load(req.Header.GUID); ok {
outcome = v.(DecisionOutcome)
}
go s.emitDecision(s.lifeCtx, req, outcome)
_ = ctx
return resp, nil
}
// SendDecision имитирует отправку нашего Decision принимающей стороне (мы — реципиент).
// Mock просто фиксирует статистику.
func (s *Sender) SendDecision(_ context.Context, decision *m2m.M2MTransferDecision) error {
if decision == nil {
return errors.New("nsdadapter/mock: SendDecision: decision=nil")
}
if err := decision.Validate(); err != nil {
return err
}
s.mu.Lock()
s.stats.Sent++
s.mu.Unlock()
return nil
}
func (s *Sender) emitDecision(ctx context.Context, req *m2m.M2MTransferRequest, outcome DecisionOutcome) {
select {
case <-ctx.Done():
return
case <-time.After(s.cfg.DecisionDelay):
}
if outcome == OutcomeTimeout {
s.mu.Lock()
s.stats.TimedOut++
s.mu.Unlock()
// При таймауте Decision не приходит вообще — это и эмулируем.
return
}
// Робот-автотест НРД: если ReceiverCode == MC0012500000 и DocumentSeries
// задан (1111/2001/2002/3333) — формируем ответ по сценарию из
// «Инструкции по тестированию с роботом» (DOC/instruktsiya-po-...pdf).
// Это позволяет проверить нашу логику обработки ответов до того, как
// у нас будет реальный ИШ + сертификат + доступ к TEST3.
if decision := simulateRobotDecision(req); decision != nil {
s.mu.Lock()
// Грубая статистика: считаем «робот-ответ» как Confirmed если хоть
// одна бумага подтверждена, иначе Rejected.
hasConfirm := false
for _, ds := range decision.Data.Securities {
if ds.TransferDecision.Confirmation != nil {
hasConfirm = true
break
}
}
if hasConfirm {
s.stats.Confirmed++
} else {
s.stats.Rejected++
}
s.mu.Unlock()
select {
case <-ctx.Done():
case s.decisions <- decision:
}
return
}
decision := &m2m.M2MTransferDecision{
Header: m2m.DecisionHeader{
GUID: req.Header.GUID,
CreationTimestamp: nsdxml.Now(),
SenderCode: req.Header.ReceiverCode,
ReceiverCode: req.Header.SenderCode,
CostInfo: m2m.CostInfo{No: &m2m.CostInfoNo{}},
},
Data: m2m.DecisionData{
ReceivingDepository: req.Data.ReceivingDepository,
},
}
for _, sec := range req.Data.TransferredSecurities.Securities {
ds := m2m.DecisionSecurity{ReferenceID: sec.ReferenceID}
if outcome == OutcomeConfirm {
ds.TransferDecision = m2m.DecisionTransfer{
Confirmation: &m2m.Confirmation{
SettlementAccount: sec.SettlementAccount[0],
},
}
} else {
ds.TransferDecision = m2m.DecisionTransfer{
Rejection: &m2m.Rejection{
Codes: []string{s.cfg.RejectionCode},
},
}
}
decision.Data.Securities = append(decision.Data.Securities, ds)
}
s.mu.Lock()
switch outcome {
case OutcomeConfirm:
s.stats.Confirmed++
case OutcomeReject:
s.stats.Rejected++
}
s.mu.Unlock()
select {
case <-ctx.Done():
case s.decisions <- decision:
}
}
// Verify тип Sender удовлетворяет m2mcore.NSDSender.
var _ m2mcore.NSDSender = (*Sender)(nil)