9737c787f9
Инфраструктура M2M (живой обмен с НРД через ИШ): - обработка M2MTransferResponse: ERROR(M2Mxx) → заявка Отклонена, сохранение ответа; INFO → ждём Decision; идемпотентность поллера - fallback-корреляция ответов с нулевым GUID (M2M14/M2M17) по FIFO - сырой XML ответа НРД в карточке заявки (для пересылки в ТП) - тестовый пакет роботу приведён к эталону m2m_robot_samples (CostInfo=Yes, 4 бумаги, IsolationStatus, DocumentSeries=сценарий); override паспорта - редирект из теста сразу в карточку заявки Мастер установки ключа Валидаты на флешку (admin/setup/keywizard): - пошаговый: загрузка .7z+пароль → выбор флешки → запись → справочник сертификатов (CRL) → перезапуск+проверка ИШ → готово - привилегированный воркер (bj-keymedia) в host-namespace через файл-обмен, bj-server остаётся в песочнице - сохранение структуры профиля архива (spr<N>), перечисление съёмных USB Прочее: - пакет-доказательство для ТП НРД + форма регистрации участника M2M - эталонные образцы робота (DOC/m2m_robot_samples) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
229 lines
9.1 KiB
Go
229 lines
9.1 KiB
Go
package m2m_test
|
||
|
||
import (
|
||
"encoding/xml"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"reflect"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
|
||
)
|
||
|
||
// validator — общий интерфейс для каскадной валидации в тестах.
|
||
type validator interface {
|
||
Validate() error
|
||
}
|
||
|
||
// roundTripCase описывает приёмочный кейс round-trip на эталонном файле.
|
||
type roundTripCase struct {
|
||
path string
|
||
mk func() any
|
||
}
|
||
|
||
// roundTripCases — соответствие "файл -> ожидаемый Go-тип сообщения".
|
||
var roundTripCases = []roundTripCase{
|
||
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferRequest.xml"), func() any { return new(m2m.M2MTransferRequest) }},
|
||
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferDecision.xml"), func() any { return new(m2m.M2MTransferDecision) }},
|
||
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferResponse.xml"), func() any { return new(m2m.M2MTransferResponse) }},
|
||
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferHandbook.xml"), func() any { return new(m2m.M2MTransferHandbook) }},
|
||
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferHandbookRequest.xml"), func() any { return new(m2m.M2MTransferHandbookRequest) }},
|
||
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferParticipantForm.xml"), func() any { return new(m2m.M2MTransferParticipantForm) }},
|
||
{filepath.Join("..", "..", "DOC", "Эталонные сообщения", "M2MTransferRequest_эталон.xml"), func() any { return new(m2m.M2MTransferRequest) }},
|
||
{filepath.Join("..", "..", "DOC", "Эталонные сообщения", "M2MTransferDecision_эталон.xml"), func() any { return new(m2m.M2MTransferDecision) }},
|
||
}
|
||
|
||
// clearXMLNameSpace обнуляет поле Space у верхнеуровневого xml.Name (если
|
||
// есть), чтобы round-trip-сравнение не зависело от namespace корня входного
|
||
// документа.
|
||
func clearXMLNameSpace(v any) {
|
||
rv := reflect.ValueOf(v)
|
||
if rv.Kind() != reflect.Ptr || rv.IsNil() {
|
||
return
|
||
}
|
||
rv = rv.Elem()
|
||
if rv.Kind() != reflect.Struct {
|
||
return
|
||
}
|
||
f := rv.FieldByName("XMLName")
|
||
if f.IsValid() && f.CanSet() && f.Type() == reflect.TypeOf(xml.Name{}) {
|
||
f.Set(reflect.ValueOf(xml.Name{Local: f.Interface().(xml.Name).Local}))
|
||
}
|
||
}
|
||
|
||
func TestRoundTrip(t *testing.T) {
|
||
for _, c := range roundTripCases {
|
||
c := c
|
||
t.Run(filepath.Base(c.path), func(t *testing.T) {
|
||
b1, err := os.ReadFile(c.path)
|
||
if err != nil {
|
||
t.Fatalf("read %s: %v", c.path, err)
|
||
}
|
||
|
||
s1 := c.mk()
|
||
if err := nsdxml.Unmarshal(b1, s1); err != nil {
|
||
t.Fatalf("unmarshal оригинала: %v", err)
|
||
}
|
||
|
||
if v, ok := s1.(validator); ok {
|
||
if err := v.Validate(); err != nil {
|
||
t.Fatalf("Validate после первого Unmarshal: %v", err)
|
||
}
|
||
}
|
||
|
||
b2, err := nsdxml.Marshal(s1)
|
||
if err != nil {
|
||
t.Fatalf("marshal: %v", err)
|
||
}
|
||
|
||
// Проверяем, что пролог windows-1251 проставлен.
|
||
if !strings.HasPrefix(string(nsdxml.DecodeWindows1251(b2)), `<?xml version="1.0" encoding="windows-1251"?>`) {
|
||
t.Fatalf("в выходе нет windows-1251 пролога")
|
||
}
|
||
|
||
s2 := c.mk()
|
||
if err := nsdxml.Unmarshal(b2, s2); err != nil {
|
||
t.Fatalf("unmarshal после Marshal: %v", err)
|
||
}
|
||
|
||
// XMLName.Space — это метаданные пространства имён входного XML,
|
||
// а не полезная нагрузка. Часть документов НРД присылает с
|
||
// namespace на корне (официальные примеры), часть — без (реальный
|
||
// ответ робота МОСТ). Парсер намеренно namespace-agnostic, поэтому
|
||
// после re-marshal корневой namespace может отличаться. Для
|
||
// приёмочных (receive-only) документов это несущественно — сравниваем
|
||
// без учёта XMLName.Space.
|
||
clearXMLNameSpace(s1)
|
||
clearXMLNameSpace(s2)
|
||
if !reflect.DeepEqual(s1, s2) {
|
||
t.Errorf("round-trip структуры разошлись:\nS1 = %+v\nS2 = %+v", s1, s2)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestValidatorsPositive(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
v validator
|
||
}{
|
||
{"ReferenceId", m2m.ReferenceID("M2M2026030200001")},
|
||
{"ISIN", m2m.ISIN("RU0007661625")},
|
||
{"OrganizationINN", m2m.OrganizationINN("7702070139")},
|
||
{"DeponentCode", m2m.DeponentCode("MC0079200000")},
|
||
{"UUID", m2m.UUID("c02a1d5e-c2af-4799-bab4-953f133c5133")},
|
||
{"SecurityCode", m2m.SecurityCode("MM0766162534")},
|
||
{"IdentityDocSerial", m2m.IdentityDocSerial("4512")},
|
||
{"AccountId", m2m.AccountID("31MC0021900000F01")},
|
||
{"StatusCode", m2m.StatusInfo},
|
||
{"IIAContractType", m2m.IIAContractT03},
|
||
{"SecurityClassification", m2m.SecurityBond},
|
||
{"SecurityCategory", m2m.CategoryOrdn},
|
||
{"IdentityDocumentCode", m2m.DocCode21},
|
||
{"IsolationStatus", m2m.IsolationSGDN},
|
||
{"Decimal16", m2m.Decimal16("2500.75")},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
if err := c.v.Validate(); err != nil {
|
||
t.Errorf("ожидалось без ошибок, получено: %v", err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestValidatorsNegative(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
v validator
|
||
}{
|
||
{"ReferenceId короткий", m2m.ReferenceID("M2M123")},
|
||
{"ReferenceId без префикса", m2m.ReferenceID("XYZ2026030200001")},
|
||
{"ISIN короткий", m2m.ISIN("RU0007")},
|
||
{"ISIN с lowercase", m2m.ISIN("ru0007661625")},
|
||
{"INN короткий", m2m.OrganizationINN("123")},
|
||
{"INN с буквами", m2m.OrganizationINN("770207013A")},
|
||
{"DeponentCode пустой", m2m.DeponentCode("")},
|
||
{"DeponentCode lowercase", m2m.DeponentCode("mc007920")},
|
||
{"DeponentCode слишком длинный", m2m.DeponentCode("AAAAAAAAAAAAA")},
|
||
{"UUID без дефисов", m2m.UUID("c02a1d5ec2af4799bab4953f133c5133aaaa")},
|
||
{"UUID с не-hex символом", m2m.UUID("c02a1d5e-c2af-4799-babZ-953f133c5133")},
|
||
{"UUID короткий", m2m.UUID("c02a1d5e-c2af-4799-bab4-953f133c513")},
|
||
{"SecurityCode короткий", m2m.SecurityCode("ABC")},
|
||
{"SecurityCode с lowercase", m2m.SecurityCode("mm0766162534")},
|
||
{"IdentityDocSerial пустой", m2m.IdentityDocSerial("")},
|
||
{"IdentityDocSerial с пробелом", m2m.IdentityDocSerial("45 12")},
|
||
{"AccountId пустой", m2m.AccountID("")},
|
||
{"AccountId длиннее 50", m2m.AccountID(strings.Repeat("A", 51))},
|
||
{"StatusCode unknown", m2m.StatusCode("OK")},
|
||
{"IIAContractType T01", m2m.IIAContractType("T01")},
|
||
{"SecurityClassification unknown", m2m.SecurityClassification("STCK")},
|
||
{"SecurityCategory unknown", m2m.SecurityCategory("XXXX")},
|
||
{"IdentityDocumentCode 99", m2m.IdentityDocumentCode("99")},
|
||
{"IsolationStatus FOO", m2m.IsolationStatus("FOO")},
|
||
{"Decimal16 пустой", m2m.Decimal16("")},
|
||
{"Decimal16 с буквами", m2m.Decimal16("12a.5")},
|
||
{"Decimal16 слишком много дробных", m2m.Decimal16("1.12345678901234567")},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
err := c.v.Validate()
|
||
if err == nil {
|
||
t.Errorf("ожидалась ошибка")
|
||
return
|
||
}
|
||
if !errors.Is(err, m2m.ErrInvalid) {
|
||
t.Errorf("ожидалась ErrInvalid, получено: %v", err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestChoiceValidators(t *testing.T) {
|
||
t.Run("CostInfo пустой", func(t *testing.T) {
|
||
if err := (m2m.CostInfo{}).Validate(); !errors.Is(err, m2m.ErrChoice) {
|
||
t.Errorf("ожидалась ErrChoice, получено: %v", err)
|
||
}
|
||
})
|
||
t.Run("CostInfo оба поля", func(t *testing.T) {
|
||
c := m2m.CostInfo{
|
||
Yes: &m2m.CostInfoYes{Code: "MC0010300032"},
|
||
No: &m2m.CostInfoNo{},
|
||
}
|
||
if err := c.Validate(); !errors.Is(err, m2m.ErrChoice) {
|
||
t.Errorf("ожидалась ErrChoice, получено: %v", err)
|
||
}
|
||
})
|
||
t.Run("Quantity пустой", func(t *testing.T) {
|
||
if err := (m2m.Quantity{}).Validate(); !errors.Is(err, m2m.ErrChoice) {
|
||
t.Errorf("ожидалась ErrChoice, получено: %v", err)
|
||
}
|
||
})
|
||
t.Run("Quantity оба поля", func(t *testing.T) {
|
||
whole := uint64(100)
|
||
frac := m2m.Decimal16("1.5")
|
||
q := m2m.Quantity{Whole: &whole, Fractional: &frac}
|
||
if err := q.Validate(); !errors.Is(err, m2m.ErrChoice) {
|
||
t.Errorf("ожидалась ErrChoice, получено: %v", err)
|
||
}
|
||
})
|
||
t.Run("SecurityDetails пустой", func(t *testing.T) {
|
||
if err := (m2m.SecurityDetails{}).Validate(); !errors.Is(err, m2m.ErrChoice) {
|
||
t.Errorf("ожидалась ErrChoice, получено: %v", err)
|
||
}
|
||
})
|
||
t.Run("IdentificationDetails пустой", func(t *testing.T) {
|
||
if err := (m2m.IdentificationDetails{}).Validate(); !errors.Is(err, m2m.ErrChoice) {
|
||
t.Errorf("ожидалась ErrChoice, получено: %v", err)
|
||
}
|
||
})
|
||
t.Run("DecisionTransfer пустой", func(t *testing.T) {
|
||
if err := (m2m.DecisionTransfer{}).Validate(); !errors.Is(err, m2m.ErrChoice) {
|
||
t.Errorf("ожидалась ErrChoice, получено: %v", err)
|
||
}
|
||
})
|
||
}
|