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:
fontvielle
2026-05-14 00:30:46 +03:00
parent 3437590d44
commit 1d6ab86a57
11 changed files with 1795 additions and 25 deletions
+200
View File
@@ -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)
}
})
}