feat(m2m-core): FSM сделки, репозиторий, идемпотентность по GUID, метрики SLA

- internal/m2mcore/fsm.go: конечный автомат с переходами Draft → Validated → SubmittedToNSD → AwaitingDecision → Confirmed → AwaitingSUB16 → Done, ветки Rejected/TimedOut/ManualApproval
- internal/m2mcore/deal.go: доменная модель Deal с методами Validate/Submit/ReceiveDecision/Timeout/SendToManualApproval/ApproveManually/RejectManually/CompleteSUB16, журнал событий
- internal/m2mcore/uuid.go: генератор UUID v4 без внешних зависимостей (crypto/rand)
- internal/m2mcore/repo.go: порт Repository + MemoryRepository с идемпотентным Create по GUID
- internal/m2mcore/ports.go: порты NSDSender/LKCallbackClient/CryptoVerifier/FansyStore с no-op заглушками для M1
- internal/m2mcore/enrich.go: EnrichRequest — сборка M2MTransferRequest из ClaimInput + Fansy, генерация ReferenceID по каждой ЦБ
- internal/m2mcore/metrics.go: порт Recorder + MemoryRecorder в Prometheus-text формате
- cmd/m2m-core/main.go: HTTP-сервер с /healthz и /metrics, graceful shutdown
- migrations/m2m-core/001__deals.sql: схема для PostgreSQL-Repository (для M2)

Покрытие: 63.1%. make ci зелёный. Без внешних Go-зависимостей (pgx и
prometheus подключим в M2, когда прокси zetit откроет Go-модули).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
fontvielle
2026-05-14 00:52:12 +03:00
parent a040f8b07d
commit 9e6e95f431
16 changed files with 1455 additions and 1 deletions
+49
View File
@@ -0,0 +1,49 @@
# internal/m2mcore — ядро бизнес-логики M2M
Конечный автомат сделки, репозиторий, идемпотентность по GUID,
сборка `M2MTransferRequest` из заявки ЛК + данных Fansy, метрики SLA.
## Состав
- `fsm.go` — состояния FSM и матрица разрешённых переходов
(`Draft → Validated → SubmittedToNSD → AwaitingDecision → Confirmed →
AwaitingSUB16 → Done`, ветки `Rejected`, `TimedOut`,
`ManualApproval`).
- `deal.go` — доменная модель `Deal` с явными методами переходов
(`Validate`, `Submit`, `ReceiveDecision`, `Timeout`,
`SendToManualApproval`, `ApproveManually`, `RejectManually`,
`CompleteSUB16`). Каждый переход проверяет текущее состояние,
пишет историю в `Stages` и фиксирует событие в журнале.
- `uuid.go` — генератор UUID v4 без внешних зависимостей.
- `repo.go` — порт `Repository` + in-memory реализация
`MemoryRepository` с идемпотентным `Create` (по GUID возвращает
существующую сделку). PostgreSQL-реализация — задача M2 (миграция
лежит в `migrations/m2m-core/001__deals.sql`).
- `ports.go` — порты к внешним системам (`NSDSender`,
`LKCallbackClient`, `CryptoVerifier`, `FansyStore`) с no-op
заглушками для M1.
- `enrich.go` — функция `EnrichRequest`: из `ClaimInput` (заявка ЛК)
+ данных Fansy собирает валидный `M2MTransferRequest`, генерирует
`GUID` и `ReferenceID` по каждой ЦБ.
- `metrics.go` — порт `Recorder` + `MemoryRecorder`, отдающий
Prometheus text-format. В M2 заменим на `prometheus/client_golang`,
когда прокси откроет Go-модули.
## Зависимости
Только stdlib и собственные пакеты `internal/m2m`, `internal/nsdxml`.
Никаких внешних модулей.
## Тесты
- `fsm_test.go` — переходы и терминальные состояния.
- `repo_test.go` — идемпотентность по GUID, фильтры в `List`.
- `uuid_test.go` — формат UUID v4 и `ReferenceID`.
- `metrics_test.go` — Prometheus-текст.
## Сервис cmd/m2m-core
`cmd/m2m-core/main.go` — HTTP-сервер с `/healthz` и `/metrics`,
in-memory репозиторий, no-op порты. Адрес из `BJ_HTTP_ADDR`
(по умолчанию `:8081`). В M2 будет заменено на реальные клиенты НРД
и crypto-service.
+232
View File
@@ -0,0 +1,232 @@
package m2mcore
import (
"context"
"fmt"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
)
// StageRecord — запись о входе в состояние FSM, для аудита и метрик.
type StageRecord struct {
State State
EnteredAt time.Time
LeftAt *time.Time
Reason string
}
// Event — доменное событие сделки (event sourcing для аудита).
type Event struct {
Type string
Payload any
CreatedAt time.Time
Actor string
}
// Deal — корневая агрегатная сущность M2M-сделки.
type Deal struct {
ID string
GUID m2m.UUID
State State
InvestorID string
SignedClaim []byte
Request *m2m.M2MTransferRequest
Response *m2m.M2MTransferResponse
Decision *m2m.M2MTransferDecision
CreatedAt time.Time
UpdatedAt time.Time
Stages []StageRecord
mu sync.Mutex
events []Event
}
// NewDeal создаёт новую сделку в состоянии Draft.
func NewDeal(guid m2m.UUID, investorID string, signedClaim []byte) (*Deal, error) {
if err := guid.Validate(); err != nil {
return nil, fmt.Errorf("m2mcore: невалидный GUID при создании Deal: %w", err)
}
id, err := NewUUIDv4()
if err != nil {
return nil, err
}
now := time.Now().UTC()
return &Deal{
ID: id,
GUID: guid,
State: StateDraft,
InvestorID: investorID,
SignedClaim: signedClaim,
CreatedAt: now,
UpdatedAt: now,
Stages: []StageRecord{{State: StateDraft, EnteredAt: now}},
}, nil
}
// Events возвращает накопленные события (копия слайса).
func (d *Deal) Events() []Event {
d.mu.Lock()
defer d.mu.Unlock()
out := make([]Event, len(d.events))
copy(out, d.events)
return out
}
// recordEvent добавляет событие в журнал сделки.
func (d *Deal) recordEvent(eventType string, payload any, actor string) {
d.events = append(d.events, Event{
Type: eventType, Payload: payload,
CreatedAt: time.Now().UTC(), Actor: actor,
})
}
// shiftTo переводит FSM в новое состояние, фиксируя историю.
// Должен вызываться под d.mu.Lock.
func (d *Deal) shiftTo(next State, reason string) error {
if err := transition(d.State, next); err != nil {
return err
}
now := time.Now().UTC()
if i := len(d.Stages) - 1; i >= 0 {
d.Stages[i].LeftAt = &now
}
d.Stages = append(d.Stages, StageRecord{State: next, EnteredAt: now, Reason: reason})
d.State = next
d.UpdatedAt = now
return nil
}
// Validate переводит Draft -> Validated и фиксирует событие.
// Сама валидация Request делается m2m.M2MTransferRequest.Validate(),
// которое следует вызвать перед этим методом.
func (d *Deal) Validate(_ context.Context, request *m2m.M2MTransferRequest) error {
d.mu.Lock()
defer d.mu.Unlock()
if request == nil {
return fmt.Errorf("m2mcore: Deal.Validate: request=nil")
}
if err := request.Validate(); err != nil {
return fmt.Errorf("m2mcore: M2MTransferRequest.Validate: %w", err)
}
d.Request = request
if err := d.shiftTo(StateValidated, ""); err != nil {
return err
}
d.recordEvent("validated", request.Header.GUID, "system")
return nil
}
// Submit переводит Validated -> SubmittedToNSD после успешной отправки.
func (d *Deal) Submit(_ context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
if err := d.shiftTo(StateSubmittedToNSD, ""); err != nil {
return err
}
d.recordEvent("submitted_to_nsd", nil, "system")
if err := d.shiftTo(StateAwaitingDecision, ""); err != nil {
return err
}
d.recordEvent("awaiting_decision", nil, "system")
return nil
}
// ReceiveDecision принимает M2MTransferDecision от принимающей стороны и
// меняет состояние на Confirmed или Rejected в зависимости от содержимого.
func (d *Deal) ReceiveDecision(_ context.Context, decision *m2m.M2MTransferDecision) error {
d.mu.Lock()
defer d.mu.Unlock()
if decision == nil {
return fmt.Errorf("m2mcore: ReceiveDecision: decision=nil")
}
if err := decision.Validate(); err != nil {
return fmt.Errorf("m2mcore: M2MTransferDecision.Validate: %w", err)
}
d.Decision = decision
// Если все Security содержат Confirmation — Confirmed; если хотя бы
// одна Rejection — Rejected. Смешанные сценарии XSD НРД не запрещает,
// но на нашей стороне их трактуем как Rejected (требует ручного разбора).
rejected := false
for _, s := range decision.Data.Securities {
if s.TransferDecision.Rejection != nil {
rejected = true
break
}
}
target := StateConfirmed
reason := ""
if rejected {
target = StateRejected
reason = "decision_contains_rejection"
}
if err := d.shiftTo(target, reason); err != nil {
return err
}
d.recordEvent("decision_received", decision.Header.GUID, "nsd")
return nil
}
// Timeout переводит сделку в TimedOut (когда не дождались Decision).
func (d *Deal) Timeout(_ context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
if err := d.shiftTo(StateTimedOut, "no_decision_within_sla"); err != nil {
return err
}
d.recordEvent("timed_out", nil, "system")
return nil
}
// SendToManualApproval переводит на ручной разбор оператора.
func (d *Deal) SendToManualApproval(_ context.Context, reason string) error {
d.mu.Lock()
defer d.mu.Unlock()
if err := d.shiftTo(StateManualApproval, reason); err != nil {
return err
}
d.recordEvent("manual_approval_requested", reason, "system")
return nil
}
// ApproveManually вручную подтверждает сделку (с операторской подписью).
func (d *Deal) ApproveManually(_ context.Context, operator string) error {
d.mu.Lock()
defer d.mu.Unlock()
if err := d.shiftTo(StateConfirmed, "manual_approve"); err != nil {
return err
}
d.recordEvent("manual_approve", nil, operator)
return nil
}
// RejectManually вручную отказывает в сделке.
func (d *Deal) RejectManually(_ context.Context, operator, code, comment string) error {
d.mu.Lock()
defer d.mu.Unlock()
if err := d.shiftTo(StateRejected, "manual_reject:"+code); err != nil {
return err
}
d.recordEvent("manual_reject", map[string]string{"code": code, "comment": comment}, operator)
return nil
}
// CompleteSUB16 фиксирует получение SUB16 от НРД и переводит Confirmed
// -> AwaitingSUB16 -> Done. Может вызываться один раз.
func (d *Deal) CompleteSUB16(_ context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.State == StateConfirmed {
if err := d.shiftTo(StateAwaitingSUB16, ""); err != nil {
return err
}
d.recordEvent("awaiting_sub16", nil, "nsd")
}
if err := d.shiftTo(StateDone, ""); err != nil {
return err
}
d.recordEvent("done", nil, "nsd")
return nil
}
+157
View File
@@ -0,0 +1,157 @@
package m2mcore
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
)
// ClaimInput — входная заявка из ЛК (после OpenAPI-парсинга).
type ClaimInput struct {
InvestorClientID string
TransferringDepositoryINN m2m.OrganizationINN
ReceivingDepositoryINN m2m.OrganizationINN
CostInfo m2m.CostInfo
IIAAgreement *m2m.IIAAgreementDetails
Securities []ClaimSecurityInput
}
// ClaimSecurityInput — одна ЦБ в заявке.
type ClaimSecurityInput struct {
SecurityCode m2m.SecurityCode
Details m2m.SecurityDetails
Quantity m2m.Quantity
}
// SenderReceiver — отправитель и получатель в Header (коды депонентов).
type SenderReceiver struct {
SenderCode m2m.DeponentCode
ReceiverCode m2m.DeponentCode
}
// referenceIDChars — алфавит для генерации ReferenceID (5 случайных
// символов после префикса "M2M" и даты).
const referenceIDChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// NewReferenceID генерирует идентификатор операции в формате
// "M2M" + YYYYMMDD + 5 случайных символов из [A-Z0-9].
// Длина — ровно 16, как требует XSD ReferenceIdType.
func NewReferenceID(at time.Time) (m2m.ReferenceID, error) {
suffix := make([]byte, 5)
for i := range suffix {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(referenceIDChars))))
if err != nil {
return "", fmt.Errorf("m2mcore: NewReferenceID rand: %w", err)
}
suffix[i] = referenceIDChars[n.Int64()]
}
day := at.UTC()
id := fmt.Sprintf("M2M%04d%02d%02d%s",
day.Year(), int(day.Month()), day.Day(), suffix)
out := m2m.ReferenceID(id)
if err := out.Validate(); err != nil {
return "", err
}
return out, nil
}
// EnrichRequest собирает M2MTransferRequest из заявки ЛК и данных Fansy.
// Шаги:
// 1. Поднять анкету клиента.
// 2. Поднять депо-счета у передающего депозитария и проверить остатки.
// 3. Сгенерировать GUID, ReferenceID для каждой ЦБ, CreationTimestamp.
// 4. Заполнить структуру и провалидировать.
func EnrichRequest(
ctx context.Context,
store FansyStore,
claim ClaimInput,
codes SenderReceiver,
) (*m2m.M2MTransferRequest, error) {
client, err := store.GetClientByID(ctx, claim.InvestorClientID)
if err != nil {
return nil, fmt.Errorf("m2mcore: GetClientByID: %w", err)
}
accounts, err := store.GetDepoAccounts(ctx, claim.InvestorClientID, claim.TransferringDepositoryINN)
if err != nil {
return nil, fmt.Errorf("m2mcore: GetDepoAccounts: %w", err)
}
if len(accounts) == 0 {
return nil, fmt.Errorf("m2mcore: у клиента нет активных счетов в передающем депозитарии")
}
guid, err := NewUUIDv4()
if err != nil {
return nil, err
}
now := time.Now()
securities := make([]m2m.RequestSecurity, 0, len(claim.Securities))
for _, sec := range claim.Securities {
refID, err := NewReferenceID(now)
if err != nil {
return nil, err
}
// Берём первый активный счёт как минимум; реальная логика выбора
// settlement_accounts будет в M2 (по типу ЦБ и торговому разделу).
settlement := make([]m2m.RequestSettlementAccount, 0, len(accounts))
for _, a := range accounts {
settlement = append(settlement, m2m.RequestSettlementAccount{
SettlementRequisites: m2m.SettlementRequisites{INN: a.DepositoryINN},
SettlementLocation: m2m.SettlementDepositoryLocation{
DeponentCode: a.DeponentCode,
AccountID: a.AccountID,
SectionID: a.SectionID,
},
})
}
securities = append(securities, m2m.RequestSecurity{
ReferenceID: refID,
SecurityCode: sec.SecurityCode,
SecurityDetails: sec.Details,
Quantity: sec.Quantity,
SettlementAccount: settlement,
IsolationStatus: m2m.IsolationSGDN,
})
}
req := &m2m.M2MTransferRequest{
Header: m2m.RequestHeader{
GUID: m2m.UUID(guid),
CreationTimestamp: nsdxml.Now(),
SenderCode: codes.SenderCode,
ReceiverCode: codes.ReceiverCode,
CostInfo: claim.CostInfo,
IIAAgreementDetails: claim.IIAAgreement,
},
Data: m2m.RequestData{
InvestorInformation: m2m.InvestorInformation{
LastName: client.LastName,
FirstName: client.FirstName,
MiddleName: client.MiddleName,
IdentityDocument: m2m.IdentityDocument{
DocumentType: client.Document.DocumentType,
DocumentNumber: m2m.IdentityDocSerial(client.Document.Number),
},
},
TransferringDepository: m2m.SettlementRequisites{INN: claim.TransferringDepositoryINN},
ReceivingDepository: m2m.SettlementRequisites{INN: claim.ReceivingDepositoryINN},
TransferredSecurities: m2m.RequestTransferredSecurities{Securities: securities},
},
}
if claim.IIAAgreement != nil {
req.Header.IIAAgreementDetails = claim.IIAAgreement
}
if client.Document.Series != "" {
series := m2m.IdentityDocSerial(client.Document.Series)
req.Data.InvestorInformation.IdentityDocument.DocumentSeries = &series
}
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("m2mcore: собранный M2MTransferRequest невалиден: %w", err)
}
return req, nil
}
+150
View File
@@ -0,0 +1,150 @@
package m2mcore_test
import (
"context"
"errors"
"testing"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
// fakeStore — тестовая реализация FansyStore.
type fakeStore struct {
client *m2mcore.Client
accounts []m2mcore.DepoAccount
getErr error
}
func (f *fakeStore) GetClientByID(_ context.Context, _ string) (*m2mcore.Client, error) {
if f.getErr != nil {
return nil, f.getErr
}
return f.client, nil
}
func (f *fakeStore) GetDepoAccounts(_ context.Context, _ string, _ m2m.OrganizationINN) ([]m2mcore.DepoAccount, error) {
return f.accounts, nil
}
func (f *fakeStore) GetBalances(_ context.Context, _ string, _ []m2m.SecurityCode) ([]m2mcore.SecurityBalance, error) {
return nil, nil
}
func TestNewReferenceIDFormat(t *testing.T) {
at := time.Date(2026, 3, 2, 14, 30, 0, 0, time.UTC)
id, err := m2mcore.NewReferenceID(at)
if err != nil {
t.Fatal(err)
}
if string(id)[:11] != "M2M20260302" {
t.Errorf("префикс/дата ReferenceID неверен: %q", id)
}
}
func TestEnrichRequestHappyPath(t *testing.T) {
store := &fakeStore{
client: &m2mcore.Client{
ID: "inv-1",
LastName: "Иванов",
FirstName: "Иван",
MiddleName: "Иванович",
Document: m2mcore.ClientDocument{
DocumentType: m2m.DocCode21,
Series: "4512",
Number: "654321",
},
},
accounts: []m2mcore.DepoAccount{
{
ID: "acc-1",
ClientID: "inv-1",
DeponentCode: "DP789456",
AccountID: m2m.AccountID("31MC0021900000F01"),
SectionID: "P001",
DepositoryINN: m2m.OrganizationINN("7702070139"),
},
},
}
whole := uint64(1500)
isin := m2m.ISIN("RU0007661625")
claim := m2mcore.ClaimInput{
InvestorClientID: "inv-1",
TransferringDepositoryINN: m2m.OrganizationINN("0702345678"),
ReceivingDepositoryINN: m2m.OrganizationINN("0710987654"),
CostInfo: m2m.CostInfo{
No: &m2m.CostInfoNo{},
},
Securities: []m2mcore.ClaimSecurityInput{
{
SecurityCode: m2m.SecurityCode("MM0766162534"),
Details: m2m.SecurityDetails{ISIN: &isin},
Quantity: m2m.Quantity{Whole: &whole},
},
},
}
codes := m2mcore.SenderReceiver{
SenderCode: m2m.DeponentCode("MC0079200000"),
ReceiverCode: m2m.DeponentCode("MC0010300000"),
}
req, err := m2mcore.EnrichRequest(context.Background(), store, claim, codes)
if err != nil {
t.Fatalf("EnrichRequest: %v", err)
}
if err := req.Validate(); err != nil {
t.Fatalf("собранный Request не прошёл валидацию: %v", err)
}
if req.Data.InvestorInformation.LastName != "Иванов" {
t.Errorf("LastName не пробросился")
}
if len(req.Data.TransferredSecurities.Securities) != 1 {
t.Errorf("ожидалась 1 ЦБ, получено %d", len(req.Data.TransferredSecurities.Securities))
}
}
func TestEnrichRequestNoAccounts(t *testing.T) {
store := &fakeStore{
client: &m2mcore.Client{LastName: "X", FirstName: "Y", Document: m2mcore.ClientDocument{DocumentType: m2m.DocCode21, Number: "1"}},
accounts: nil,
}
_, err := m2mcore.EnrichRequest(context.Background(), store, m2mcore.ClaimInput{}, m2mcore.SenderReceiver{})
if err == nil {
t.Errorf("ожидалась ошибка при отсутствии счетов")
}
}
func TestEnrichRequestStoreError(t *testing.T) {
store := &fakeStore{getErr: errors.New("db down")}
_, err := m2mcore.EnrichRequest(context.Background(), store, m2mcore.ClaimInput{}, m2mcore.SenderReceiver{})
if err == nil {
t.Errorf("ожидалась ошибка от FansyStore")
}
}
func TestNoopPortsReturnErrNotImplemented(t *testing.T) {
ctx := context.Background()
if _, err := (m2mcore.NoopNSDSender{}).Send(ctx, nil); !errors.Is(err, m2mcore.ErrNotImplemented) {
t.Errorf("NoopNSDSender.Send ожидалась ErrNotImplemented, получено %v", err)
}
if err := (m2mcore.NoopNSDSender{}).SendDecision(ctx, nil); !errors.Is(err, m2mcore.ErrNotImplemented) {
t.Errorf("NoopNSDSender.SendDecision ожидалась ErrNotImplemented, получено %v", err)
}
if err := (m2mcore.NoopLKCallbackClient{}).UpdateStatus(ctx, "", "", ""); !errors.Is(err, m2mcore.ErrNotImplemented) {
t.Errorf("LKCallbackClient ожидалась ErrNotImplemented")
}
if _, err := (m2mcore.NoopCryptoVerifier{}).VerifyXMLDSig(ctx, nil); !errors.Is(err, m2mcore.ErrNotImplemented) {
t.Errorf("CryptoVerifier ожидалась ErrNotImplemented")
}
if _, err := (m2mcore.NoopFansyStore{}).GetClientByID(ctx, ""); !errors.Is(err, m2mcore.ErrNotImplemented) {
t.Errorf("FansyStore.GetClientByID ожидалась ErrNotImplemented")
}
if _, err := (m2mcore.NoopFansyStore{}).GetDepoAccounts(ctx, "", ""); !errors.Is(err, m2mcore.ErrNotImplemented) {
t.Errorf("FansyStore.GetDepoAccounts ожидалась ErrNotImplemented")
}
if _, err := (m2mcore.NoopFansyStore{}).GetBalances(ctx, "", nil); !errors.Is(err, m2mcore.ErrNotImplemented) {
t.Errorf("FansyStore.GetBalances ожидалась ErrNotImplemented")
}
}
+96
View File
@@ -0,0 +1,96 @@
package m2mcore
import (
"errors"
"fmt"
)
// State — конечное состояние сделки M2M.
type State string
const (
StateDraft State = "draft"
StateValidated State = "validated"
StateSubmittedToNSD State = "submitted_to_nsd"
StateAwaitingDecision State = "awaiting_decision"
StateConfirmed State = "confirmed"
StateAwaitingSUB16 State = "awaiting_sub16"
StateDone State = "done"
StateRejected State = "rejected"
StateTimedOut State = "timed_out"
StateManualApproval State = "manual_approval"
)
// ErrInvalidTransition возвращается при попытке перейти в состояние,
// которое не разрешено из текущего.
var ErrInvalidTransition = errors.New("m2mcore: недопустимый переход FSM")
// allowedTransitions — карта разрешённых переходов FSM сделки.
// Любая попытка перейти в state, отсутствующее в списке для текущего,
// заканчивается ErrInvalidTransition.
var allowedTransitions = map[State]map[State]struct{}{
StateDraft: {
StateValidated: {},
StateRejected: {},
StateManualApproval: {},
},
StateValidated: {
StateSubmittedToNSD: {},
StateRejected: {},
StateManualApproval: {},
},
StateSubmittedToNSD: {
StateAwaitingDecision: {},
StateRejected: {},
},
StateAwaitingDecision: {
StateConfirmed: {},
StateRejected: {},
StateTimedOut: {},
StateManualApproval: {},
},
StateConfirmed: {
StateAwaitingSUB16: {},
StateDone: {},
},
StateAwaitingSUB16: {
StateDone: {},
},
StateManualApproval: {
StateValidated: {},
StateConfirmed: {},
StateRejected: {},
},
// Завершающие состояния — без выходов.
StateDone: {},
StateRejected: {},
StateTimedOut: {},
}
// IsTerminal возвращает true для завершающих состояний.
func IsTerminal(s State) bool {
switch s {
case StateDone, StateRejected, StateTimedOut:
return true
}
return false
}
// CanTransition сообщает, разрешён ли переход from -> to.
func CanTransition(from, to State) bool {
allowed, ok := allowedTransitions[from]
if !ok {
return false
}
_, ok = allowed[to]
return ok
}
// transition проверяет переход и возвращает обёрнутую ошибку с
// контекстом, если он недопустим.
func transition(from, to State) error {
if !CanTransition(from, to) {
return fmt.Errorf("%w: %s -> %s", ErrInvalidTransition, from, to)
}
return nil
}
+96
View File
@@ -0,0 +1,96 @@
package m2mcore_test
import (
"context"
"errors"
"testing"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
func newTestDeal(t *testing.T) *m2mcore.Deal {
t.Helper()
guid, err := m2mcore.NewUUIDv4()
if err != nil {
t.Fatalf("UUID: %v", err)
}
d, err := m2mcore.NewDeal(m2m.UUID(guid), "00000000-0000-0000-0000-000000000001", []byte("<xml/>"))
if err != nil {
t.Fatalf("NewDeal: %v", err)
}
return d
}
func TestFSMAllowedTransitions(t *testing.T) {
cases := []struct {
from m2mcore.State
to m2mcore.State
ok bool
}{
{m2mcore.StateDraft, m2mcore.StateValidated, true},
{m2mcore.StateDraft, m2mcore.StateSubmittedToNSD, false},
{m2mcore.StateValidated, m2mcore.StateSubmittedToNSD, true},
{m2mcore.StateAwaitingDecision, m2mcore.StateConfirmed, true},
{m2mcore.StateAwaitingDecision, m2mcore.StateRejected, true},
{m2mcore.StateAwaitingDecision, m2mcore.StateTimedOut, true},
{m2mcore.StateConfirmed, m2mcore.StateAwaitingSUB16, true},
{m2mcore.StateDone, m2mcore.StateRejected, false},
{m2mcore.StateRejected, m2mcore.StateDone, false},
}
for _, c := range cases {
if got := m2mcore.CanTransition(c.from, c.to); got != c.ok {
t.Errorf("CanTransition(%s,%s)=%v ожидалось %v", c.from, c.to, got, c.ok)
}
}
}
func TestFSMIsTerminal(t *testing.T) {
for _, s := range []m2mcore.State{m2mcore.StateDone, m2mcore.StateRejected, m2mcore.StateTimedOut} {
if !m2mcore.IsTerminal(s) {
t.Errorf("%s должно быть терминальным", s)
}
}
for _, s := range []m2mcore.State{m2mcore.StateDraft, m2mcore.StateConfirmed} {
if m2mcore.IsTerminal(s) {
t.Errorf("%s не должно быть терминальным", s)
}
}
}
func TestDealManualReject(t *testing.T) {
d := newTestDeal(t)
if err := d.SendToManualApproval(context.Background(), "ambiguous_decision"); err != nil {
t.Fatalf("SendToManualApproval из Draft неожиданно дал ошибку %v", err)
}
}
func TestDealInvalidTransition(t *testing.T) {
d := newTestDeal(t)
// Draft -> SubmittedToNSD не разрешён.
err := d.Submit(context.Background())
if !errors.Is(err, m2mcore.ErrInvalidTransition) {
t.Errorf("ожидалась ErrInvalidTransition, получено %v", err)
}
}
func TestDealCompleteSUB16(t *testing.T) {
d := newTestDeal(t)
if err := d.ApproveManually(context.Background(), "operator-1"); err == nil {
// Из Draft нельзя сразу ApproveManually — нужен путь через
// ManualApproval. Это тоже проверяем.
t.Errorf("ApproveManually из Draft должен был отказать")
}
if err := d.SendToManualApproval(context.Background(), "test"); err != nil {
t.Fatal(err)
}
if err := d.ApproveManually(context.Background(), "operator-1"); err != nil {
t.Fatalf("ApproveManually: %v", err)
}
if err := d.CompleteSUB16(context.Background()); err != nil {
t.Fatalf("CompleteSUB16: %v", err)
}
if d.State != m2mcore.StateDone {
t.Errorf("конечное состояние %s, ожидалось %s", d.State, m2mcore.StateDone)
}
}
+16
View File
@@ -0,0 +1,16 @@
package m2mcore_test
import (
"testing"
"time"
)
// mustParseTime — общий тестовый хелпер.
func mustParseTime(t *testing.T, s string) time.Time {
t.Helper()
tt, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatalf("parse time: %v", err)
}
return tt
}
+142
View File
@@ -0,0 +1,142 @@
package m2mcore
import (
"fmt"
"io"
"sort"
"strings"
"sync"
"time"
)
// Recorder — интерфейс записи метрик. NoopRecorder используется по
// умолчанию; реальная реализация (Prometheus) подключается в M2, когда
// внешние Go-зависимости становятся доступны.
type Recorder interface {
StageDuration(stage State, d time.Duration)
IncDeal(state State)
IncSLABreach(stage State, budget string)
}
// NoopRecorder ничего не пишет.
type NoopRecorder struct{}
// StageDuration — no-op.
func (NoopRecorder) StageDuration(State, time.Duration) {}
// IncDeal — no-op.
func (NoopRecorder) IncDeal(State) {}
// IncSLABreach — no-op.
func (NoopRecorder) IncSLABreach(State, string) {}
// MemoryRecorder — простая in-memory реализация Recorder, удобная для
// тестов и для /metrics-endpoint в M1 (Prometheus text format).
type MemoryRecorder struct {
mu sync.Mutex
// stageDurations: stage -> сумма длительностей и счётчик
stageDurations map[State]struct {
Sum time.Duration
Count uint64
}
dealsByState map[State]uint64
slaBreaches map[string]uint64 // ключ: "stage|budget"
}
// NewMemoryRecorder создаёт MemoryRecorder с пустыми счётчиками.
func NewMemoryRecorder() *MemoryRecorder {
return &MemoryRecorder{
stageDurations: make(map[State]struct {
Sum time.Duration
Count uint64
}),
dealsByState: make(map[State]uint64),
slaBreaches: make(map[string]uint64),
}
}
// StageDuration фиксирует длительность этапа FSM.
func (m *MemoryRecorder) StageDuration(stage State, d time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
v := m.stageDurations[stage]
v.Sum += d
v.Count++
m.stageDurations[stage] = v
}
// IncDeal увеличивает счётчик сделок в заданном итоговом состоянии.
func (m *MemoryRecorder) IncDeal(state State) {
m.mu.Lock()
defer m.mu.Unlock()
m.dealsByState[state]++
}
// IncSLABreach фиксирует превышение SLA-бюджета на этапе.
func (m *MemoryRecorder) IncSLABreach(stage State, budget string) {
m.mu.Lock()
defer m.mu.Unlock()
m.slaBreaches[fmt.Sprintf("%s|%s", stage, budget)]++
}
// WritePrometheus сериализует накопленные метрики в формате Prometheus
// text exposition (HELP/TYPE + значения). Подходит для /metrics.
func (m *MemoryRecorder) WritePrometheus(w io.Writer) error {
m.mu.Lock()
defer m.mu.Unlock()
fmt.Fprintf(w, "# HELP m2m_stage_duration_seconds_sum Сумма длительностей этапа в секундах\n")
fmt.Fprintf(w, "# TYPE m2m_stage_duration_seconds_sum counter\n")
stages := sortedStates(m.stageDurations)
for _, s := range stages {
v := m.stageDurations[s]
fmt.Fprintf(w, "m2m_stage_duration_seconds_sum{stage=%q} %f\n", s, v.Sum.Seconds())
fmt.Fprintf(w, "m2m_stage_duration_seconds_count{stage=%q} %d\n", s, v.Count)
}
fmt.Fprintf(w, "# HELP m2m_deals_total Сделки по итоговому состоянию\n")
fmt.Fprintf(w, "# TYPE m2m_deals_total counter\n")
dstates := sortedStatesCount(m.dealsByState)
for _, s := range dstates {
fmt.Fprintf(w, "m2m_deals_total{state=%q} %d\n", s, m.dealsByState[s])
}
fmt.Fprintf(w, "# HELP m2m_sla_breaches_total Превышения SLA-бюджета\n")
fmt.Fprintf(w, "# TYPE m2m_sla_breaches_total counter\n")
keys := make([]string, 0, len(m.slaBreaches))
for k := range m.slaBreaches {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
stage, budget, ok := strings.Cut(k, "|")
if !ok {
continue
}
fmt.Fprintf(w, "m2m_sla_breaches_total{stage=%q,budget=%q} %d\n", stage, budget, m.slaBreaches[k])
}
return nil
}
// sortedStates возвращает состояния как отсортированный слайс для
// детерминированного вывода.
func sortedStates(m map[State]struct {
Sum time.Duration
Count uint64
}) []State {
out := make([]State, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func sortedStatesCount(m map[State]uint64) []State {
out := make([]State, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
+44
View File
@@ -0,0 +1,44 @@
package m2mcore_test
import (
"bytes"
"strings"
"testing"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
func TestMemoryRecorderPrometheus(t *testing.T) {
r := m2mcore.NewMemoryRecorder()
r.StageDuration(m2mcore.StateAwaitingDecision, 12*time.Second)
r.StageDuration(m2mcore.StateAwaitingDecision, 8*time.Second)
r.IncDeal(m2mcore.StateConfirmed)
r.IncDeal(m2mcore.StateConfirmed)
r.IncDeal(m2mcore.StateRejected)
r.IncSLABreach(m2mcore.StateAwaitingDecision, "5m")
var buf bytes.Buffer
if err := r.WritePrometheus(&buf); err != nil {
t.Fatal(err)
}
out := buf.String()
for _, want := range []string{
`m2m_stage_duration_seconds_count{stage="awaiting_decision"} 2`,
`m2m_stage_duration_seconds_sum{stage="awaiting_decision"} 20`,
`m2m_deals_total{state="confirmed"} 2`,
`m2m_deals_total{state="rejected"} 1`,
`m2m_sla_breaches_total{stage="awaiting_decision",budget="5m"} 1`,
} {
if !strings.Contains(out, want) {
t.Errorf("в выводе нет %q\n---\n%s", want, out)
}
}
}
func TestNoopRecorder(t *testing.T) {
var r m2mcore.Recorder = m2mcore.NoopRecorder{}
r.StageDuration(m2mcore.StateConfirmed, time.Second)
r.IncDeal(m2mcore.StateConfirmed)
r.IncSLABreach(m2mcore.StateAwaitingDecision, "5m")
}
+130
View File
@@ -0,0 +1,130 @@
package m2mcore
import (
"context"
"errors"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
)
// CertInfo — описание подписанта (заполняется CryptoVerifier).
type CertInfo struct {
SignerCN string
SignerINN string
Serial string
NotBefore time.Time
NotAfter time.Time
}
// Client — анкета инвестора, нужная для enrich Request.
type Client struct {
ID string
LastName string
FirstName string
MiddleName string
BirthDate *time.Time
Document ClientDocument
}
// ClientDocument — документ, удостоверяющий личность.
type ClientDocument struct {
DocumentType m2m.IdentityDocumentCode
Series string
Number string
}
// DepoAccount — счёт депо инвестора у депозитария.
type DepoAccount struct {
ID string
ClientID string
DeponentCode string
AccountID m2m.AccountID
SectionID string
DepositoryINN m2m.OrganizationINN
}
// SecurityBalance — остаток по ценной бумаге на конкретном счёте депо.
type SecurityBalance struct {
SecurityCode m2m.SecurityCode
ISIN m2m.ISIN
QuantityWhole *uint64
QuantityFractional *m2m.Decimal16
IsolationStatus m2m.IsolationStatus
ValuedAt time.Time
}
// NSDSender — порт отправки в НРД (через ИШ или резервный канал WS ONYX).
type NSDSender interface {
// Send отправляет запрос на перевод и возвращает квитанцию НРД.
Send(ctx context.Context, req *m2m.M2MTransferRequest) (*m2m.M2MTransferResponse, error)
// SendDecision отправляет решение принимающей стороны.
SendDecision(ctx context.Context, decision *m2m.M2MTransferDecision) error
}
// LKCallbackClient — порт уведомления ЛК клиента об изменении статуса.
type LKCallbackClient interface {
UpdateStatus(ctx context.Context, claimID, status, reason string) error
}
// CryptoVerifier — порт проверки и формирования XMLDSig-подписей.
type CryptoVerifier interface {
VerifyXMLDSig(ctx context.Context, payload []byte) (CertInfo, error)
}
// FansyStore — порт чтения данных из принимающей БД fansy-store.
type FansyStore interface {
GetClientByID(ctx context.Context, id string) (*Client, error)
GetDepoAccounts(ctx context.Context, clientID string, depositoryINN m2m.OrganizationINN) ([]DepoAccount, error)
GetBalances(ctx context.Context, depoAccountID string, codes []m2m.SecurityCode) ([]SecurityBalance, error)
}
// ErrNotImplemented возвращается заглушками портов.
var ErrNotImplemented = errors.New("m2mcore: не реализовано (M1 заглушка)")
// NoopNSDSender — заглушка NSDSender для M1.
type NoopNSDSender struct{}
// Send возвращает ErrNotImplemented.
func (NoopNSDSender) Send(context.Context, *m2m.M2MTransferRequest) (*m2m.M2MTransferResponse, error) {
return nil, ErrNotImplemented
}
// SendDecision возвращает ErrNotImplemented.
func (NoopNSDSender) SendDecision(context.Context, *m2m.M2MTransferDecision) error {
return ErrNotImplemented
}
// NoopLKCallbackClient — заглушка LKCallbackClient для M1.
type NoopLKCallbackClient struct{}
// UpdateStatus возвращает ErrNotImplemented.
func (NoopLKCallbackClient) UpdateStatus(context.Context, string, string, string) error {
return ErrNotImplemented
}
// NoopCryptoVerifier — заглушка CryptoVerifier для M1.
type NoopCryptoVerifier struct{}
// VerifyXMLDSig возвращает ErrNotImplemented.
func (NoopCryptoVerifier) VerifyXMLDSig(context.Context, []byte) (CertInfo, error) {
return CertInfo{}, ErrNotImplemented
}
// NoopFansyStore — заглушка FansyStore для M1.
type NoopFansyStore struct{}
// GetClientByID возвращает ErrNotImplemented.
func (NoopFansyStore) GetClientByID(context.Context, string) (*Client, error) {
return nil, ErrNotImplemented
}
// GetDepoAccounts возвращает ErrNotImplemented.
func (NoopFansyStore) GetDepoAccounts(context.Context, string, m2m.OrganizationINN) ([]DepoAccount, error) {
return nil, ErrNotImplemented
}
// GetBalances возвращает ErrNotImplemented.
func (NoopFansyStore) GetBalances(context.Context, string, []m2m.SecurityCode) ([]SecurityBalance, error) {
return nil, ErrNotImplemented
}
+155
View File
@@ -0,0 +1,155 @@
package m2mcore
import (
"context"
"errors"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
)
// ErrNotFound возвращается, когда сделка не найдена.
var ErrNotFound = errors.New("m2mcore: сделка не найдена")
// Filter описывает фильтры выборки сделок.
type Filter struct {
State *State
InvestorID string
CreatedFrom *time.Time
CreatedTo *time.Time
Limit int
Offset int
}
// Repository — порт хранилища сделок.
type Repository interface {
// Create вставляет сделку с идемпотентностью по GUID: повторный вызов
// для существующего GUID возвращает уже существующую сделку (без
// модификации).
Create(ctx context.Context, deal *Deal) (*Deal, error)
// GetByGUID находит сделку по M2M GUID.
GetByGUID(ctx context.Context, guid m2m.UUID) (*Deal, error)
// GetByID находит сделку по внутреннему UUID.
GetByID(ctx context.Context, id string) (*Deal, error)
// Update сохраняет изменения сделки.
Update(ctx context.Context, deal *Deal) error
// List возвращает сделки по фильтру.
List(ctx context.Context, f Filter) ([]*Deal, error)
// AppendEvent добавляет аудит-событие к сделке.
AppendEvent(ctx context.Context, dealID string, ev Event) error
}
// MemoryRepository — in-memory реализация Repository для тестов и
// dev-стенда без PostgreSQL.
type MemoryRepository struct {
mu sync.RWMutex
byID map[string]*Deal
byGUID map[m2m.UUID]string
events map[string][]Event
}
// NewMemoryRepository создаёт пустое in-memory хранилище.
func NewMemoryRepository() *MemoryRepository {
return &MemoryRepository{
byID: make(map[string]*Deal),
byGUID: make(map[m2m.UUID]string),
events: make(map[string][]Event),
}
}
// Create вставляет сделку или возвращает существующую по GUID.
func (r *MemoryRepository) Create(_ context.Context, deal *Deal) (*Deal, error) {
r.mu.Lock()
defer r.mu.Unlock()
if id, ok := r.byGUID[deal.GUID]; ok {
return r.byID[id], nil
}
r.byID[deal.ID] = deal
r.byGUID[deal.GUID] = deal.ID
return deal, nil
}
// GetByGUID возвращает сделку по GUID или ErrNotFound.
func (r *MemoryRepository) GetByGUID(_ context.Context, guid m2m.UUID) (*Deal, error) {
r.mu.RLock()
defer r.mu.RUnlock()
id, ok := r.byGUID[guid]
if !ok {
return nil, ErrNotFound
}
return r.byID[id], nil
}
// GetByID возвращает сделку по внутреннему ID или ErrNotFound.
func (r *MemoryRepository) GetByID(_ context.Context, id string) (*Deal, error) {
r.mu.RLock()
defer r.mu.RUnlock()
d, ok := r.byID[id]
if !ok {
return nil, ErrNotFound
}
return d, nil
}
// Update в in-memory импликации no-op: указатель уже хранится.
func (r *MemoryRepository) Update(_ context.Context, deal *Deal) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.byID[deal.ID]; !ok {
return ErrNotFound
}
return nil
}
// List перебирает сделки и фильтрует на лету.
func (r *MemoryRepository) List(_ context.Context, f Filter) ([]*Deal, error) {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]*Deal, 0)
for _, d := range r.byID {
if f.State != nil && d.State != *f.State {
continue
}
if f.InvestorID != "" && d.InvestorID != f.InvestorID {
continue
}
if f.CreatedFrom != nil && d.CreatedAt.Before(*f.CreatedFrom) {
continue
}
if f.CreatedTo != nil && d.CreatedAt.After(*f.CreatedTo) {
continue
}
out = append(out, d)
}
if f.Offset > 0 && f.Offset < len(out) {
out = out[f.Offset:]
} else if f.Offset >= len(out) {
return nil, nil
}
if f.Limit > 0 && f.Limit < len(out) {
out = out[:f.Limit]
}
return out, nil
}
// AppendEvent добавляет событие в журнал сделки.
func (r *MemoryRepository) AppendEvent(_ context.Context, dealID string, ev Event) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.byID[dealID]; !ok {
return ErrNotFound
}
r.events[dealID] = append(r.events[dealID], ev)
return nil
}
// EventsOf возвращает все события сделки (только для тестов и дев-логов).
func (r *MemoryRepository) EventsOf(dealID string) []Event {
r.mu.RLock()
defer r.mu.RUnlock()
src := r.events[dealID]
out := make([]Event, len(src))
copy(out, src)
return out
}
+82
View File
@@ -0,0 +1,82 @@
package m2mcore_test
import (
"context"
"errors"
"testing"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
func TestRepoCreateIdempotentByGUID(t *testing.T) {
ctx := context.Background()
repo := m2mcore.NewMemoryRepository()
// Используем валидный 36-символьный UUID (XSD НРД не требует битов
// версии/варианта, только длину и hex).
const guidStr = "c02a1d5e-c2af-4799-bab4-953f133c5133"
d1, err := m2mcore.NewDeal(m2m.UUID(guidStr), "inv-1", []byte("a"))
if err != nil {
t.Fatal(err)
}
d2, err := m2mcore.NewDeal(m2m.UUID(guidStr), "inv-1", []byte("a"))
if err != nil {
t.Fatal(err)
}
saved1, err := repo.Create(ctx, d1)
if err != nil {
t.Fatal(err)
}
saved2, err := repo.Create(ctx, d2)
if err != nil {
t.Fatal(err)
}
if saved1.ID != saved2.ID {
t.Errorf("идемпотентность нарушена: %s != %s", saved1.ID, saved2.ID)
}
}
func TestRepoGetByGUIDNotFound(t *testing.T) {
repo := m2mcore.NewMemoryRepository()
_, err := repo.GetByGUID(context.Background(), m2m.UUID("c02a1d5e-c2af-4799-bab4-953f133c5133"))
if !errors.Is(err, m2mcore.ErrNotFound) {
t.Errorf("ожидалась ErrNotFound, получено %v", err)
}
}
func TestRepoListFilters(t *testing.T) {
ctx := context.Background()
repo := m2mcore.NewMemoryRepository()
mkDeal := func(guid, inv string) {
t.Helper()
d, err := m2mcore.NewDeal(m2m.UUID(guid), inv, []byte("x"))
if err != nil {
t.Fatal(err)
}
if _, err := repo.Create(ctx, d); err != nil {
t.Fatal(err)
}
}
mkDeal("00000000-0000-0000-0000-000000000001", "inv-A")
mkDeal("00000000-0000-0000-0000-000000000002", "inv-A")
mkDeal("00000000-0000-0000-0000-000000000003", "inv-B")
all, err := repo.List(ctx, m2mcore.Filter{})
if err != nil {
t.Fatal(err)
}
if len(all) != 3 {
t.Errorf("ожидалось 3 сделки, получено %d", len(all))
}
onlyA, err := repo.List(ctx, m2mcore.Filter{InvestorID: "inv-A"})
if err != nil {
t.Fatal(err)
}
if len(onlyA) != 2 {
t.Errorf("ожидалось 2 сделки inv-A, получено %d", len(onlyA))
}
}
+20
View File
@@ -0,0 +1,20 @@
package m2mcore
import (
"crypto/rand"
"fmt"
)
// NewUUIDv4 генерирует UUID v4 в каноническом формате 8-4-4-4-12. Без
// внешних зависимостей: использует crypto/rand и выставляет биты
// версии (4) и варианта (RFC 4122) согласно стандарту.
func NewUUIDv4() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("m2mcore: чтение crypto/rand: %w", err)
}
b[6] = (b[6] & 0x0F) | 0x40 // версия 4 в старших битах 7-го байта
b[8] = (b[8] & 0x3F) | 0x80 // RFC 4122 вариант (10xx) в 9-м байте
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
+45
View File
@@ -0,0 +1,45 @@
package m2mcore_test
import (
"regexp"
"testing"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
func TestNewUUIDv4Format(t *testing.T) {
pattern := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
seen := make(map[string]struct{})
for i := 0; i < 100; i++ {
u, err := m2mcore.NewUUIDv4()
if err != nil {
t.Fatal(err)
}
if !pattern.MatchString(u) {
t.Errorf("UUID %q не соответствует RFC4122 v4", u)
}
if _, dup := seen[u]; dup {
t.Errorf("сгенерирован дубликат %q", u)
}
seen[u] = struct{}{}
// Также должен проходить XSD-валидатор НРД.
if err := (m2m.UUID(u)).Validate(); err != nil {
t.Errorf("UUID %q отвергнут XSD-валидатором: %v", u, err)
}
}
}
func TestNewReferenceID(t *testing.T) {
at := mustParseTime(t, "2026-03-02T14:30:45Z")
id, err := m2mcore.NewReferenceID(at)
if err != nil {
t.Fatal(err)
}
if len(id) != 16 {
t.Errorf("ReferenceID длина %d, ожидалось 16", len(id))
}
if err := id.Validate(); err != nil {
t.Errorf("ReferenceID %q отвергнут валидатором: %v", id, err)
}
}