Мониторинг связи с НРД: участки Шлюз/НРД/M2M/Отправка, сквозная проверка справочником раз в 10 мин, история за сутки на «Статусе», цепочка в шапке (30 с), автоповтор недоставленных до 24 ч, баннер и статус «ожидает отправки» в кабинете клиента

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-09-25 16:17:25 +03:00
parent a0f6ce9f7b
commit 4cbefa41d5
18 changed files with 1075 additions and 62 deletions
+187
View File
@@ -0,0 +1,187 @@
package lkgateway
import (
"bytes"
"context"
"path/filepath"
"strings"
"testing"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
func nsdTestRC(t *testing.T) *RuntimeConfig {
t.Helper()
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
if err != nil {
t.Fatal(err)
}
n := rc.Snapshot().NSD
n.IGWBaseURL = "http://localhost:8090"
if err := rc.UpdateNSD(n); err != nil {
t.Fatal(err)
}
return rc
}
func nsdTestReset(t *testing.T) {
t.Helper()
old := sendFailFile
sendFailFile = filepath.Join(t.TempDir(), "sf.json")
sendFails.loaded = false
oldHist := nsdHistoryFile
nsdHistoryFile = filepath.Join(t.TempDir(), "h.json")
t.Cleanup(func() {
sendFailFile, sendFails.loaded, nsdHistoryFile = old, false, oldHist
igwWatch.mu.Lock()
igwWatch.Checked, igwWatch.Up = false, false
igwWatch.mu.Unlock()
nsdWatch.Lock()
nsdWatch.DownSince, nsdWatch.LastPollAt, nsdWatch.LastPollOK = time.Time{}, time.Time{}, time.Time{}
nsdWatch.Unlock()
nsdH.Lock()
nsdH.ProbeSentAt, nsdH.ProbeReplyAt, nsdH.ProbeErr, nsdH.history, nsdH.historyLoaded = time.Time{}, time.Time{}, "", nil, false
nsdH.Unlock()
})
}
func segState(h NSDHealth, key string) string {
for _, s := range h.Segs {
if s.Key == key {
return s.State
}
}
return ""
}
// Зелёный — только при свежих подтверждениях; сбой опросов и молчание на проверку — красный.
func TestNSDHealthStates(t *testing.T) {
nsdTestReset(t)
rc := nsdTestRC(t)
now := time.Now()
igwWatch.mu.Lock()
igwWatch.Checked, igwWatch.Up = true, true
igwWatch.mu.Unlock()
nsdWatch.Lock()
nsdWatch.LastPollAt, nsdWatch.LastPollOK = now.Add(-10*time.Second), now.Add(-10*time.Second)
nsdWatch.Unlock()
nsdH.Lock()
nsdH.ProbeSentAt, nsdH.ProbeReplyAt = now.Add(-5*time.Minute), now.Add(-4*time.Minute)
nsdH.Unlock()
h := nsdHealthNow(rc, now)
if h.State != segOK || !nsdLinkOK(h) || nsdClientNotice(h) != "" || h.Ago == "" {
t.Fatalf("всё работает: %+v", h)
}
nsdWatch.Lock()
nsdWatch.DownSince, nsdWatch.LastErr = now.Add(-5*time.Minute), "#404"
nsdWatch.Unlock()
h = nsdHealthNow(rc, now)
if segState(h, "nsd") != segErr || h.State != segErr || nsdLinkOK(h) || nsdClientNotice(h) == "" {
t.Fatalf("НРД отвечает ошибками 5 мин: %+v", h)
}
nsdWatch.Lock()
nsdWatch.DownSince = time.Time{}
nsdWatch.LastPollOK = now.Add(-20 * time.Minute) // тишина — не «работает»
nsdWatch.Unlock()
if h = nsdHealthNow(rc, now); segState(h, "nsd") != segWarn {
t.Fatalf("давно нет опросов: %+v", h)
}
nsdWatch.Lock()
nsdWatch.LastPollOK = now
nsdWatch.Unlock()
nsdH.Lock()
nsdH.ProbeSentAt = now.Add(-12 * time.Minute)
nsdH.ProbeReplyAt = now.Add(-25 * time.Minute)
nsdH.Unlock()
if h = nsdHealthNow(rc, now); segState(h, "m2m") != segErr {
t.Fatalf("НРД молчит на проверку 12 мин: %+v", h)
}
}
// Автоповтор: НРД снова отвечает — недоставленный перевод уходит сам; старше суток — не трогаем.
func TestAutoResend(t *testing.T) {
nsdTestReset(t)
oldHook, oldQ := sentHook, quikSendHook
defer func() { sentHook, quikSendHook = oldHook, oldQ }()
sentHook, quikSendHook = nil, nil
ctx := context.Background()
snd := &flakySender{fail: true}
svc := NewService(Config{Repository: m2mcore.NewMemoryRepository(), Sender: snd})
s := &Server{svc: svc}
req := sendFailRequest()
if _, err := svc.SendBuiltRequest(ctx, req, "inv", nil); err == nil {
t.Fatal("ожидалась ошибка доставки")
}
guid := string(req.Header.GUID)
s.autoResend(ctx) // последняя попытка только что — ждём 5 мин
if sendFailGet(guid) == nil {
t.Fatal("рано повторять")
}
sendFails.Lock()
f := sendFails.m[guid]
f.At = time.Now().Add(-6 * time.Minute)
sendFails.m[guid] = f
sendFails.Unlock()
snd.fail = false
s.autoResend(ctx)
if sendFailGet(guid) != nil {
t.Fatal("перевод должен уйти автоматически")
}
d, _ := svc.Repo().GetByGUID(ctx, req.Header.GUID)
if d.State != m2mcore.StateAwaitingDecision {
t.Fatalf("после автоповтора: %s", d.State)
}
// Старше суток — автоповтор останавливается.
sendFails.Lock()
sendFails.m["old"] = SendFailure{DealID: "x", At: time.Now().Add(-26 * time.Hour), FirstAt: time.Now().Add(-26 * time.Hour), Attempts: 9}
sendFails.Unlock()
s.autoResend(ctx)
if f := sendFailGet("old"); f == nil || !f.GaveUp {
t.Fatalf("старше суток: %+v", f)
}
}
func TestNSDStripAndStatusRender(t *testing.T) {
nsdTestReset(t)
now := time.Now()
nsdH.Lock()
nsdH.historyLoaded = true
nsdH.Unlock()
for i := 30; i >= 0; i-- {
st := segOK
if i < 5 {
st = segErr
}
nsdHistoryAdd(NSDHealth{Configured: true, Segs: []NSDSeg{{State: segOK}, {State: st}, {State: segOK}, {State: segOK}}}, now.Add(-time.Duration(i)*time.Minute))
}
rows := nsdStrip([]string{"Шлюз", "НРД", "M2M", "Отправка"}, now)
if len(rows) != 4 || len(rows[1].Cells) != 144 || rows[1].Cells[143].State != segErr || rows[0].Bad != "сбоев не было" || !strings.HasPrefix(rows[1].Bad, "сбой") {
t.Fatalf("полоса: %+v / %q", rows[1].Cells[143], rows[1].Bad)
}
a, err := newAdmin()
if err != nil {
t.Fatal(err)
}
pg := page{Title: "Статус"}
pg.NSDLink = NSDHealth{Configured: true, State: segErr, Segs: []NSDSeg{{Key: "igw", Title: "Шлюз", State: segOK, Text: "отвечает"}, {Key: "nsd", Title: "НРД", State: segErr, Text: "отвечает шлюзу ошибками"}}}
var b bytes.Buffer
data := statusData{page: pg, Strip: rows, Fails: []SendFailView{{GUID: "d7a182d4-4f79", DealID: "7f1c5cfb", Text: "контур НРД не принял пакет", FirstAt: now, Attempts: 2}}}
if err := a.status.ExecuteTemplate(&b, "layout", data); err != nil {
t.Fatal(err)
}
for _, want := range []string{"Связь с НРД", "nsd-cells", "Не доставлены в НРД", "Проверить связь сейчас", "id=\"nsd-chain\"", "отправится сам"} {
if !strings.Contains(b.String(), want) {
t.Fatalf("нет %q", want)
}
}
}