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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user