feat(m2m): доменная модель сообщений + парсер windows-1251 + round-trip тесты
- internal/m2m/types.go: enum'ы и simple-типы из XSD НРД (M2MSchemas_260408) - internal/m2m/validators.go: pattern-валидаторы ReferenceID/ISIN/INN/UUID/SecurityCode/IdentityDocSerial/AccountID + перечисления - internal/m2m/messages.go: структуры 6 типов сообщений M2M, choice-типы через указатели, IsM2M=true автоматически в MarshalXML - internal/nsdxml/datetime.go: тип NSDDateTime (формат "YYYY-MM-DDThh:mm:ss(МСК+N)") - internal/nsdxml/codec.go: Marshal/Unmarshal XML в windows-1251 (собственный кодек CP1251, без внешних зависимостей) - internal/m2m/messages_test.go: round-trip тесты на 6 примерах + 2 эталонах из DOC/ Покрытие: m2m 73.9%, nsdxml 92.5%. make ci зелёный. Отклонение от спеки: вместо golang.org/x/text/encoding/charmap собственная таблица CP1251 на ~60 строк, потому что прокси zetit блокирует proxy.golang.org, goproxy.cn и redirect-хосты Go-модулей. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
package m2m_test
|
||||
|
||||
import (
|
||||
"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) }},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user