Files
Bridge-and-Join-s/internal/lkgateway/emulators_test.go
T
zuevav 6c42e0378d feat(admin): эмулятор МНП — режим реальной записи в МНП на подставного клиента
Третий режим МНП (между «внутренняя имитация» и «боевая запись»): в bj цикл
идёт по обычному пользователю, а поручение в QUIK МНП уходит РЕАЛЬНО, но с
кодом другого, реально существующего в QUIK клиента (обобщение ручного теста
realtest «боевой ЛК → TEST3»).

- QuikMNPSettings: RealWrite + RealClientCode (валидация формата кода).
- Подмена кода — единая точка quikClientCodeFromPort (после ClientCodeMap);
  портфель не трогаем. quikActive: RealWrite только при devMode и непустом
  коде; Emulate (внутренняя имитация) в приоритете.
- Шаги боевого пути помечаются «[на подставного клиента <код>]» — видно в
  «Поручениях QUIK».
- Карточка МНП на /admin/emulators — три взаимоисключающих режима
  (выключено / имитация / реальная запись), поле кода, крупное предупреждение;
  отдельный красный баннер «Реальные поручения в МНП на клиента <код> (режим
  разработки)» — не смешиваем с баннером эмуляции НРД.
- disableEmulatorsIfDevOff сбрасывает и RealWrite (код остаётся, инертен),
  новость явно фиксирует прекращение реальных поручений.
2026-10-08 00:45:06 +03:00

417 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
)
// Настройки эмуляторов: валидация и обратная совместимость (старый setup.json без полей = дефолты).
func TestEmulatorSettingsValidation(t *testing.T) {
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
if err != nil {
t.Fatal(err)
}
for _, s := range []NSDEmuSettings{
{DelaySec: -1}, {DelaySec: 301},
{Outcome: "explode"}, {RejectCode: "99"}, {RejectCode: "M2M07"},
} {
if err := rc.UpdateNSDEmu(s); err == nil {
t.Errorf("UpdateNSDEmu(%+v): ожидалась ошибка", s)
}
}
if err := rc.UpdateNSDEmu(NSDEmuSettings{DelaySec: 45, Outcome: "reject", RejectCode: "18"}); err != nil {
t.Fatal(err)
}
for _, s := range []MNPEmuSettings{{StepDelaySec: -1}, {StepDelaySec: 61}} {
if err := rc.UpdateMNPEmu(s); err == nil {
t.Errorf("UpdateMNPEmu(%+v): ожидалась ошибка", s)
}
}
if err := rc.UpdateMNPEmu(MNPEmuSettings{StepDelaySec: 5, FailRegister: true}); err != nil {
t.Fatal(err)
}
// Перечитываем файл: настройки переживают перезапуск.
rc2, err := NewRuntimeConfig(rc.path)
if err != nil {
t.Fatal(err)
}
s := rc2.Snapshot()
if s.NSDEmu.DelaySec != 45 || s.NSDEmu.Outcome != "reject" || s.NSDEmu.RejectCode != "18" {
t.Errorf("NSDEmu после перезагрузки: %+v", s.NSDEmu)
}
if !s.MNPEmu.FailRegister || s.MNPEmu.StepDelaySec != 5 {
t.Errorf("MNPEmu после перезагрузки: %+v", s.MNPEmu)
}
// Дефолты для нулевых полей (старый setup.json).
d := NSDEmuSettings{}.Defaults()
if d.DelaySec != 30 || d.Outcome != "confirm" || d.RejectCode != "07" {
t.Errorf("дефолты NSDEmu: %+v", d)
}
}
// Страница «Эмуляторы» — только в режиме разработки: вне него 404 (гейт в authMiddleware).
func TestEmulatorsDevGate(t *testing.T) {
for _, p := range []string{"/admin/emulators", "/admin/emulators/nsd", "/admin/emulators/quik"} {
if !devOnlyPath(p) || requiredPerm(p, http.MethodGet) != "dev" {
t.Errorf("%s должен быть служебным", p)
}
}
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
registerEmulators(mux, nil, rc)
h := authMiddleware(rc, mux)
for _, m := range []string{http.MethodGet, http.MethodPost} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(m, "/admin/emulators", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("%s вне режима разработки: %d, ожидали 404", m, rec.Code)
}
}
}
// Сохранение со страницы «Эмуляторы»: ajax-POST пишет настройки и флаги.
func TestEmulatorsSave(t *testing.T) {
devModeForced = true
defer func() { devModeForced = false }()
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
if err != nil {
t.Fatal(err)
}
a, err := newAdmin()
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
registerEmulators(mux, a, rc)
post := func(path string, form url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-BJ-Ajax", "1")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
rec := post("/admin/emulators/nsd", url.Values{
"emulate": {"1"}, "delay_sec": {"45"}, "outcome": {"reject"}, "reject_code": {"18"},
})
if rec.Code != http.StatusOK {
t.Fatalf("nsd: %d %s", rec.Code, rec.Body.String())
}
s := rc.Snapshot()
if !s.LK.EmulateNSD || s.NSDEmu.DelaySec != 45 || s.NSDEmu.Outcome != "reject" || s.NSDEmu.RejectCode != "18" {
t.Errorf("после сохранения НРД: emulate=%v emu=%+v", s.LK.EmulateNSD, s.NSDEmu)
}
rec = post("/admin/emulators/quik", url.Values{
"mode": {"emulate"}, "step_delay_sec": {"7"}, "fail_register": {"1"}, "fail_deposit": {"1"},
})
if rec.Code != http.StatusOK {
t.Fatalf("quik: %d %s", rec.Code, rec.Body.String())
}
s = rc.Snapshot()
if !s.Quik.Emulate || s.Quik.RealWrite || s.MNPEmu.StepDelaySec != 7 || !s.MNPEmu.FailRegister || !s.MNPEmu.FailDeposit || s.MNPEmu.FailSign {
t.Errorf("после сохранения МНП: emulate=%v real=%v emu=%+v", s.Quik.Emulate, s.Quik.RealWrite, s.MNPEmu)
}
// Валидация на хендлере: плохой код отказа — 400, настройки не тронуты.
rec = post("/admin/emulators/nsd", url.Values{"delay_sec": {"10"}, "outcome": {"reject"}, "reject_code": {"42"}})
if rec.Code != http.StatusBadRequest {
t.Errorf("плохой код отказа: %d, ожидали 400", rec.Code)
}
rec = post("/admin/emulators/quik", url.Values{"step_delay_sec": {"600"}})
if rec.Code != http.StatusBadRequest {
t.Errorf("задержка 600: %d, ожидали 400", rec.Code)
}
// Страница собирается и показывает обе карточки.
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/emulators", nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET: %d", rec.Code)
}
for _, want := range []string{"Эмулятор НРД", "Эмулятор МНП QUIK", "в НРД ничего не отправляется", "M2M18"} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("на странице нет %q", want)
}
}
}
// Эмулированный отказ МНП: шаг завершается как боевой отказ — QuikStep OK=false, Err заполнена,
// состояние связки не продвигается; повторный вызов без отказа проходит.
func TestMNPEmuFailRegister(t *testing.T) {
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
if err != nil {
t.Fatal(err)
}
q := rc.Snapshot().Quik
q.Emulate = true
if err := rc.UpdateQuik(q); err != nil {
t.Fatal(err)
}
if err := rc.UpdateMNPEmu(MNPEmuSettings{FailRegister: true}); err != nil {
t.Fatal(err)
}
l, err := QuikOutRegister(context.Background(), rc, "emu-fail-test-guid", "CL1", "", "RU0009029540", 10, "7702070139", "", false)
if err == nil {
t.Fatal("ожидался отказ регистрации")
}
if l == nil || l.State != "error" || !strings.Contains(l.Err, "имитация отказа") {
t.Fatalf("связка после отказа: %+v", l)
}
if st := l.LastStep(); st == nil || st.OK || st.Action != "register" {
t.Fatalf("шаг: %+v", st)
}
// Сняли отказ — повтор (как следующий проход цикла) проходит.
if err := rc.UpdateMNPEmu(MNPEmuSettings{}); err != nil {
t.Fatal(err)
}
l, err = QuikOutRegister(context.Background(), rc, "emu-fail-test-guid", "CL1", "", "RU0009029540", 10, "7702070139", "", false)
if err != nil || l.State != "registered" {
t.Fatalf("повтор без отказа: %v, %+v", err, l)
}
}
// Задержка шага эмулятора МНП учитывает контекст: отмена/таймаут прерывают ожидание.
func TestMNPEmuStepDelayCtx(t *testing.T) {
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
if err != nil {
t.Fatal(err)
}
q := rc.Snapshot().Quik
q.Emulate = true
if err := rc.UpdateQuik(q); err != nil {
t.Fatal(err)
}
if err := rc.UpdateMNPEmu(MNPEmuSettings{StepDelaySec: 60}); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
start := time.Now()
if _, err := QuikOutRegister(ctx, rc, "emu-delay-test-guid", "CL1", "", "RU0009029540", 5, "", "", false); err == nil {
t.Fatal("ожидался обрыв по контексту")
}
if time.Since(start) > 5*time.Second {
t.Fatal("задержка не прервалась по контексту")
}
}
// rcWithExpiredDev — действующая лицензия с истёкшим dev_until: devMode уже false.
func rcWithExpiredDev(t *testing.T) *RuntimeConfig {
t.Helper()
priv := testLicenseKey(t)
rc := testRC(t)
past := time.Now().UTC().Add(-time.Hour)
tok := signTestLicense(t, priv, &license.License{Schema: 1, ID: "lic-dev", Tenant: "Тест", Product: "bj-server", Plan: license.PlanPro,
IssuedAt: past.Add(-24 * time.Hour), ExpiresAt: time.Now().UTC().Add(60 * 24 * time.Hour), DevUntil: &past})
if err := rc.SaveLicense(LicenseSettings{Key: tok}); err != nil {
t.Fatal(err)
}
if devMode(rc) {
t.Fatal("devMode с истёкшим dev_until должен быть выключен")
}
return rc
}
// dev-режим закончился: включённые эмуляторы сбрасываются из настроек, про это — новость.
func TestDisableEmulatorsIfDevOff(t *testing.T) {
rc := rcWithExpiredDev(t)
s := rc.Snapshot()
s.LK.EmulateNSD = true
if err := rc.UpdateLK(s.LK); err != nil {
t.Fatal(err)
}
s.Quik.Emulate = true
if err := rc.UpdateQuik(s.Quik); err != nil {
t.Fatal(err)
}
disableEmulatorsIfDevOff(rc)
if s := rc.Snapshot(); s.LK.EmulateNSD || s.Quik.Emulate {
t.Fatalf("флаги не сброшены: EmulateNSD=%v Quik.Emulate=%v", s.LK.EmulateNSD, s.Quik.Emulate)
}
// setup.json на диске тоже без флагов — при следующем dev-режиме эмуляторы не оживут.
rc2, err := NewRuntimeConfig(rc.path)
if err != nil {
t.Fatal(err)
}
if s := rc2.Snapshot(); s.LK.EmulateNSD || s.Quik.Emulate {
t.Fatal("в setup.json флаги остались включёнными")
}
found := false
for _, n := range rc.Snapshot().News.Items {
if strings.Contains(n.Title, "Эмуляторы отключены") {
found = true
if !strings.Contains(n.Body, "эмулятор НРД") || !strings.Contains(n.Body, "эмулятор МНП") {
t.Errorf("в новости не перечислено, что выключено: %s", n.Body)
}
// WritesEnabled=false — новость предупреждает об остановленном цикле МНП.
if !strings.Contains(n.Body, "Цикл M2M в МНП остановлен") {
t.Errorf("нет предупреждения об остановленном цикле МНП: %s", n.Body)
}
}
}
if !found {
t.Error("новость об отключении эмуляторов не добавлена")
}
}
// dev-режим действует: авто-сброс ничего не трогает.
func TestDisableEmulatorsIfDevOffDevOn(t *testing.T) {
devModeForced = true
defer func() { devModeForced = false }()
rc := testRC(t)
s := rc.Snapshot()
s.LK.EmulateNSD = true
s.Quik.Emulate = true
if err := rc.UpdateLK(s.LK); err != nil {
t.Fatal(err)
}
if err := rc.UpdateQuik(s.Quik); err != nil {
t.Fatal(err)
}
disableEmulatorsIfDevOff(rc)
if s := rc.Snapshot(); !s.LK.EmulateNSD || !s.Quik.Emulate {
t.Fatal("при действующем dev-режиме флаги не должны сбрасываться")
}
}
// dev выключен, но флаги уже сняты: no-op — настройки не переписываются, новости нет.
func TestDisableEmulatorsIfDevOffNoOp(t *testing.T) {
rc := rcWithExpiredDev(t)
before := rc.Snapshot().UpdatedAt
disableEmulatorsIfDevOff(rc)
after := rc.Snapshot()
if !after.UpdatedAt.Equal(before) {
t.Error("no-op не должен переписывать setup.json")
}
if len(after.News.Items) != 0 {
t.Error("no-op не должен добавлять новость")
}
}
// Режимы МНП взаимоисключающие: хендлер выставляет Emulate/RealWrite по выбранному режиму.
func TestEmulatorsQuikModes(t *testing.T) {
devModeForced = true
defer func() { devModeForced = false }()
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
registerEmulators(mux, nil, rc)
post := func(form url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/admin/emulators/quik", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-BJ-Ajax", "1")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
// Реальная запись на подставного клиента.
rec := post(url.Values{"mode": {"real"}, "real_client_code": {"BR859"}})
if rec.Code != http.StatusOK {
t.Fatalf("real: %d %s", rec.Code, rec.Body.String())
}
if s := rc.Snapshot().Quik; !s.RealWrite || s.Emulate || s.RealClientCode != "BR859" {
t.Fatalf("режим real: %+v", s)
}
// Внутренняя имитация — наоборот, код сохраняется (инертен без флага).
rec = post(url.Values{"mode": {"emulate"}, "real_client_code": {"BR859"}})
if rec.Code != http.StatusOK {
t.Fatalf("emulate: %d %s", rec.Code, rec.Body.String())
}
if s := rc.Snapshot().Quik; !s.Emulate || s.RealWrite {
t.Fatalf("режим emulate: %+v", s)
}
// real без кода — 400.
if rec := post(url.Values{"mode": {"real"}}); rec.Code != http.StatusBadRequest {
t.Errorf("real без кода: %d, ожидали 400", rec.Code)
}
// Код с недопустимыми символами — 400 (валидация UpdateQuik).
if rec := post(url.Values{"mode": {"real"}, "real_client_code": {"БР 859!"}}); rec.Code != http.StatusBadRequest {
t.Errorf("плохой код: %d, ожидали 400", rec.Code)
}
}
// Подмена кода клиента QUIK в режиме реальной записи — после обычной логики (ClientCodeMap не мешает).
func TestQuikClientCodeRealWrite(t *testing.T) {
s := QuikMNPSettings{ClientCodeMap: map[string]string{"БР-7-7": "BR777"}}
if c := quikClientCodeFromPort(s, "БР-859 Баранкин"); c != "BR859" {
t.Errorf("обычный режим: %q", c)
}
if c := quikClientCodeFromPort(s, "БР-7-7 Погонщиков"); c != "BR777" {
t.Errorf("ClientCodeMap: %q", c)
}
s.RealWrite, s.RealClientCode = true, "BR859"
if c := quikClientCodeFromPort(s, "БР-7-7 Погонщиков"); c != "BR859" {
t.Errorf("подмена имеет приоритет над ClientCodeMap: %q", c)
}
if c := quikClientCodeFromPort(s, "БР-859 Баранкин"); c != "BR859" {
t.Errorf("подмена: %q", c)
}
// Флаг включён, но код не задан — обычная логика.
s.RealClientCode = ""
if c := quikClientCodeFromPort(s, "БР-7-7 Погонщиков"); c != "BR777" {
t.Errorf("real без кода: %q", c)
}
}
// Цикл МНП активен в режиме реальной записи только в dev и с указанным кодом.
func TestQuikActiveRealWrite(t *testing.T) {
if quikActive(QuikMNPSettings{RealWrite: true, RealClientCode: "BR859"}) {
t.Error("вне dev-режима цикл стоит")
}
devModeForced = true
defer func() { devModeForced = false }()
if !quikActive(QuikMNPSettings{RealWrite: true, RealClientCode: "BR859"}) {
t.Error("dev + код: цикл должен работать")
}
if quikActive(QuikMNPSettings{RealWrite: true}) {
t.Error("dev без кода: цикл стоит")
}
// Имитация имеет приоритет над real-записью.
if !quikActive(QuikMNPSettings{Emulate: true, RealWrite: true, RealClientCode: "BR859"}) {
t.Error("emulate + real: работает имитация (цикл активен)")
}
}
// Авто-сброс при окончании dev-режима гасит и реальную запись на подставного клиента.
func TestDisableEmulatorsIfDevOffRealWrite(t *testing.T) {
rc := rcWithExpiredDev(t)
q := rc.Snapshot().Quik
q.RealWrite, q.RealClientCode = true, "BR859"
if err := rc.UpdateQuik(q); err != nil {
t.Fatal(err)
}
disableEmulatorsIfDevOff(rc)
if s := rc.Snapshot().Quik; s.RealWrite {
t.Fatal("RealWrite не сброшен")
}
found := false
for _, n := range rc.Snapshot().News.Items {
if strings.Contains(n.Title, "Эмуляторы отключены") {
found = true
if !strings.Contains(n.Body, "подставного клиента BR859") || !strings.Contains(n.Body, "прекращены") {
t.Errorf("новость без упоминания реальных поручений: %s", n.Body)
}
}
}
if !found {
t.Error("новость не добавлена")
}
}