Files
HY2XS_flamy/apps/service/cron_test.go
T
founder 6d1686b2be fix(admin): свести access-control к одному правилу и одному пути отзыва
Второй разбор того же слоя, уже по состоянию после 162759c. Тема: границы между
частями access-control. Прошлый проход починил одну операцию отзыва доступа и
оставил остальные; правило доступа при этом продолжало существовать в двух
экземплярах. Проведены три границы: состояние пира -> решение о доступе,
сохранённое изменение -> живая сессия, планировщик -> принадлежащая ему работа.

Правило доступа. Оно было записано двумя разными SQL-условиями: одним в выборке
Hysteria2Auth, другим в выборке cron. Второе не является отрицанием первого, и
расхождение приходилось ровно на границы — quota=0, usage=quota, now=expiresAt,
now=bannedUntil: авторизация отказывала, cron сессию не рвал. Условие cron
требовало СТРОГОГО превышения квоты, а счётчики растут порциями по ответу
Traffic Stats API, поэтому точное равенство — обычный исход очередного сбора.
Пир с исчерпанной квотой не пускался заново, но его живая сессия не разрывалась
никогда. Политика вынесена в peerAccessDenied; авторизация ищет пира только по
secret_digest, cron применяет ту же функцию. quota=-1 — единственный безлимит,
quota=0 — ноль байтов, bannedUntil=now — блокировка уже закончилась. Строка без
решающего поля трактуется как повреждённая и ведёт к отказу.

Операции, оставлявшие живую сессию. DeletePeer состоял из одного dao.DeletePeer:
строка исчезала вместе с auth_id, то есть вместе с единственным, чем эту сессию
можно было завершить, — состояние становилось невосстановимым. Разрыв при
изменении выполнялся только при disabled=1, поэтому мимо проходили смена
секрета, урезание квоты ниже израсходованного, перенос срока в прошлое и
снижение maxDevices. Импорт переписывает auth_id, секрет, квоту, срок и disabled
целиком и не трогал сессий вовсе. Все операции идут теперь через один
reconcileLiveSessions, а он — через disconnectAuthIDs, единственный вход к /kick:
он принимает готовые идентификаторы, дедуплицирует их, разбивает на части и не
обращается к базе. Импорт собирает старые auth_id ВНУТРИ транзакции (после
commit их в базе уже нет) и рвёт ПОСЛЕ commit (до него клиент успел бы
переподключиться к ещё не изменённому пиру). Правило асимметрично намеренно:
ограничение применяется немедленно, послабление — нет.

Цикл учёта. CronHandleAccount запускала горутину, которая запускала ещё две, —
для планировщика джоба заканчивалась почти мгновенно, поэтому StopCron не ждал
настоящей работы: releaseResource закрывал SQLite, а горутины продолжали в неё
писать. Параллельность обеих половин означала ещё и то, что enforcement читал
счётчики до записи снятой дельты. Джоба стала синхронной, под одним мьютексом на
весь цикл, порядок строгий. Закрыты три nil-разыменования — trafficSecretConfig,
item.AuthId и item.Id, — каждое из которых роняло процесс целиком вместе с
обработчиком machine-auth. Гейт Hysteria2IsRunning убран: util.Exec не отличает
«служба неактивна» от «спросить не удалось», и сломанный systemctl при живой
Hysteria молча отключал и учёт, и enforcement. Потеря дельты при отказе SQLite
больше не молчит: чтение /traffic?clear=1 деструктивно, и каждая потеря
считается. Checkpoint accounting в 1.0.0 намеренно не вводится — квота здесь
операционный предел доступа, а не учёт с финансово значимым каждым байтом.

Лимит устройств. Между чтением /online и ответом allow место ничем не
удерживалось: при online=max-1 два одновременных запроса получали разрешение
оба. Мьютекс вокруг /online этого не чинит — ответив allow, админка не создаёт
подключение, и следующий запрос продолжает видеть прежнее число. Появился
process-local учёт выданных, но ещё не проявившихся разрешений: решение по сумме
«подключено плюс зарезервировано», рост online снимает соответствующее их число,
протухшие снимаются по внутреннему TTL. Сеть опрашивается вне блокировки.

Гейты. Проверка «авторизация не возвращает успех из ветки ошибки» была записана
регуляркой err != nil \{[\s\S]*?return \*peer\.Id, а ленивый [\s\S]*? свободно
пересекает границы блоков: она даёт совпадение на коде из HEAD, то есть гейт
нельзя было удовлетворить, не сломав продукт. Тело ветки теперь выделяется по
балансу фигурных скобок, и логика проверена в обе стороны. go test -race стал
обязательным шагом сборки: состояние трекера разрешений и мьютекс цикла учёта
принадлежат процессу, и их корректность не наблюдаема ни в go test, ни в go vet;
пропуск при недоступном компиляторе не предусмотрен.

Панель. importPeerApi не объявлял skipErrorToast, а handleImport не имел ни try,
ни catch: после появления частичного результата отказ уходил бы необработанным
отклонением промиса, список не обновлялся бы при уже изменённой базе, а общий
перехватчик показал бы предупреждение красной ошибкой. Формулировка
peer_disconnect_failed во всех трёх местах сделана operation-neutral: через этот
код отчитываются восемь операций, а для удалённого пира прежняя фраза «новые
подключения пира запрещены» просто бессмысленна.
2026-09-01 20:46:21 +05:00

567 lines
21 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 service
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
)
// Цикл учёта проверяется против НАСТОЯЩЕГО Traffic Stats API.
//
// accountStatsStub добавляет к trafficStatsStub то, чего у него нет: ответ
// `GET /traffic`. Разделять их не нужно — на живом сервере это один и тот же
// API на одном порту, и джоба ходит в оба маршрута подряд.
type accountStatsStub struct {
mu sync.Mutex
traffic map[string]bo.Hysteria2UserTraffic
online map[string]int64
trafficStatus int
onlineStatus int
kickStatus int
trafficCalls int
onlineCalls int
kickCalls int
kickedKeys [][]string
// trafficCleared запоминает, просила ли админка обнулить счётчики.
trafficCleared []bool
// usageAtOnline — суммарный расход пиров на момент запроса `/online`.
// Именно этим доказывается порядок «сначала учёт, потом enforcement»:
// после джобы оба шага уже выполнены и проверять там нечего.
usageAtOnline []map[string]int64
}
func (s *accountStatsStub) usageSnapshot() map[string]int64 {
usage := map[string]int64{}
peers, err := dao.ListPeer("1=1")
if err != nil {
return usage
}
for _, peer := range peers {
if peer.AuthId == nil {
continue
}
var total int64
if peer.DownloadBytes != nil {
total += *peer.DownloadBytes
}
if peer.UploadBytes != nil {
total += *peer.UploadBytes
}
usage[*peer.AuthId] = total
}
return usage
}
func startAccountStats(t *testing.T, stub *accountStatsStub) *accountStatsStub {
t.Helper()
if stub == nil {
stub = &accountStatsStub{}
}
if stub.traffic == nil {
stub.traffic = map[string]bo.Hysteria2UserTraffic{}
}
if stub.online == nil {
stub.online = map[string]int64{}
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
stub.mu.Lock()
defer stub.mu.Unlock()
switch r.URL.Path {
case "/traffic":
stub.trafficCalls++
stub.trafficCleared = append(stub.trafficCleared, r.URL.Query().Get("clear") == "1")
if stub.trafficStatus != 0 {
w.WriteHeader(stub.trafficStatus)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(stub.traffic)
case "/online":
stub.onlineCalls++
stub.usageAtOnline = append(stub.usageAtOnline, stub.usageSnapshot())
if stub.onlineStatus != 0 {
w.WriteHeader(stub.onlineStatus)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(stub.online)
case "/kick":
stub.kickCalls++
var keys []string
if err := json.NewDecoder(r.Body).Decode(&keys); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
stub.kickedKeys = append(stub.kickedKeys, keys)
if stub.kickStatus != 0 {
w.WriteHeader(stub.kickStatus)
return
}
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
pointHysteriaConfigAt(t, server.URL)
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, testTrafficStatsSecret); err != nil {
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
}
return stub
}
func (s *accountStatsStub) kicked() []string {
s.mu.Lock()
defer s.mu.Unlock()
out := []string{}
for _, keys := range s.kickedKeys {
out = append(out, keys...)
}
return out
}
// peerUsage помещает пиру расход и настройки доступа.
func peerUsage(t *testing.T, id int64, updates map[string]interface{}) {
t.Helper()
if err := dao.UpdatePeer([]int64{id}, updates); err != nil {
t.Fatalf("подготовка состояния пира: %v", err)
}
}
// --- Границы принудительного отключения --------------------------------------
// Главная регрессия QUOTA-01: cron требовал СТРОГОГО превышения квоты, а
// авторизация отказывала уже при равенстве. Пир с исчерпанной квотой не
// пускался заново, но его живая сессия не разрывалась никогда — он продолжал
// пользоваться доступом, пока не переподключался сам.
func TestCronKicksPeerAtExactQuota(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
id := seedPeer(t, "alpha1", "alpha-auth-id")
peerUsage(t, id, map[string]interface{}{
"quota_bytes": int64(1_000),
"download_bytes": int64(600),
"upload_bytes": int64(400),
})
CronHandleAccount()
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
t.Fatalf("пир с исчерпанной квотой не отключён: %v", got)
}
}
// Нулевая квота — это ноль байтов, а не безлимит. Прежнее условие
// `quota_bytes > 0` такую строку не рассматривало вовсе.
func TestCronKicksPeerWithZeroQuota(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
id := seedPeer(t, "alpha1", "alpha-auth-id")
peerUsage(t, id, map[string]interface{}{"quota_bytes": int64(0)})
CronHandleAccount()
if got := stub.kicked(); len(got) != 1 {
t.Fatalf("пир с нулевой квотой не отключён: %v", got)
}
}
func TestCronDoesNotKickUnlimitedQuota(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
id := seedPeer(t, "alpha1", "alpha-auth-id")
peerUsage(t, id, map[string]interface{}{
"quota_bytes": int64(-1),
"download_bytes": int64(1 << 40),
})
CronHandleAccount()
if got := stub.kicked(); len(got) != 0 {
t.Fatalf("безлимитный пир отключён по квоте: %v", got)
}
}
func TestCronKicksPeerWhenExpiryEqualsNow(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
id := seedPeer(t, "alpha1", "alpha-auth-id")
// Срок в недавнем прошлом: «момент наступил» и «момент прошёл» — по
// контракту одно и то же, а точное совпадение с now в тесте недостижимо.
peerUsage(t, id, map[string]interface{}{"expires_at": time.Now().UnixMilli() - 1})
CronHandleAccount()
if got := stub.kicked(); len(got) != 1 {
t.Fatalf("пир с истёкшим сроком не отключён: %v", got)
}
}
// Блокировка «до» момента, который уже наступил, закончилась: пира отключать
// не за что.
func TestCronDoesNotKickAfterBanExpired(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
id := seedPeer(t, "alpha1", "alpha-auth-id")
peerUsage(t, id, map[string]interface{}{"banned_until": time.Now().UnixMilli() - 1})
CronHandleAccount()
if got := stub.kicked(); len(got) != 0 {
t.Fatalf("пир с истёкшей блокировкой отключён: %v", got)
}
}
func TestCronKicksBannedPeer(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
id := seedPeer(t, "alpha1", "alpha-auth-id")
peerUsage(t, id, map[string]interface{}{"banned_until": time.Now().UnixMilli() + 3_600_000})
CronHandleAccount()
if got := stub.kicked(); len(got) != 1 {
t.Fatalf("заблокированный пир не отключён: %v", got)
}
}
func TestCronKicksDisabledPeer(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
id := seedPeer(t, "alpha1", "alpha-auth-id")
peerUsage(t, id, map[string]interface{}{"disabled": int64(1)})
CronHandleAccount()
if got := stub.kicked(); len(got) != 1 {
t.Fatalf("отключённый пир не отключён: %v", got)
}
}
// Действующий пир не трогается, и запрос без единой цели не отправляется вовсе:
// раньше POST с пустым массивом уезжал в Hysteria каждые 30 секунд.
func TestCronSendsNoKickWithoutTargets(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
seedPeer(t, "alpha1", "alpha-auth-id")
CronHandleAccount()
stub.mu.Lock()
calls := stub.kickCalls
stub.mu.Unlock()
if calls != 0 {
t.Fatalf("вызов /kick без единой цели: %d", calls)
}
}
// Пир, которого Hysteria не считает онлайн, в enforcement не участвует: рвать
// у него нечего.
func TestCronIgnoresOfflinePeers(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{}})
id := seedPeer(t, "alpha1", "alpha-auth-id")
peerUsage(t, id, map[string]interface{}{"disabled": int64(1)})
CronHandleAccount()
if got := stub.kicked(); len(got) != 0 {
t.Fatalf("офлайн-пир попал в /kick: %v", got)
}
}
// --- Порядок и устройство цикла ----------------------------------------------
// Enforcement принимает решение по счётчикам, поэтому счётчики обязаны быть
// обновлены ДО него. Раньше обе половины запускались параллельными горутинами,
// и превышение квоты замечалось в лучшем случае со следующего тика.
func TestCronCollectsTrafficBeforeEnforcing(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{
traffic: map[string]bo.Hysteria2UserTraffic{
"alpha-auth-id": {Rx: 600, Tx: 400},
},
online: map[string]int64{"alpha-auth-id": 1},
})
id := seedPeer(t, "alpha1", "alpha-auth-id")
peerUsage(t, id, map[string]interface{}{"quota_bytes": int64(1_000)})
CronHandleAccount()
stub.mu.Lock()
usage := stub.usageAtOnline
stub.mu.Unlock()
if len(usage) == 0 {
t.Fatal("enforcement не выполнялся")
}
if got := usage[0]["alpha-auth-id"]; got != 1_000 {
t.Fatalf("enforcement увидел расход %d — дельта ещё не была записана", got)
}
// И следствие: превышение замечено в ТОМ ЖЕ тике, а не в следующем.
if kicked := stub.kicked(); len(kicked) != 1 {
t.Fatalf("исчерпавший квоту пир не отключён в том же цикле: %v", kicked)
}
}
// Чтение трафика деструктивно по контракту Traffic Stats API: без clear=1
// счётчики Hysteria не обнуляются, и следующий сбор посчитал бы тот же трафик
// повторно.
func TestCronClearsTrafficCounters(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{
traffic: map[string]bo.Hysteria2UserTraffic{"alpha-auth-id": {Rx: 1, Tx: 1}},
})
seedPeer(t, "alpha1", "alpha-auth-id")
CronHandleAccount()
stub.mu.Lock()
cleared := stub.trafficCleared
stub.mu.Unlock()
if len(cleared) != 1 || !cleared[0] {
t.Fatalf("сбор трафика выполнен без clear=1: %v", cleared)
}
}
// Дельта трафика, которую не удалось приписать пиру, считается потерей и
// попадает в исход джобы.
//
// Чтение `?clear=1` деструктивно по контракту Traffic Stats API: счётчики
// Hysteria обнуляются сразу после отправки ответа, поэтому каждая дельта
// существует ровно в одном экземпляре. Раньше такой случай делал `continue` и
// не оставлял следа вовсе.
func TestSaveAccountTrafficCountsLostDeltas(t *testing.T) {
newTestDB(t)
startAccountStats(t, &accountStatsStub{
traffic: map[string]bo.Hysteria2UserTraffic{
"known-auth-id": {Rx: 10, Tx: 20},
"unknown-auth-id": {Rx: 30, Tx: 40},
},
})
seedPeer(t, "alpha1", "known-auth-id")
apiPort, err := GetHysteria2ApiPort()
if err != nil {
t.Fatalf("порт Traffic Stats API: %v", err)
}
err = saveAccountTraffic(apiPort, testTrafficStatsSecret)
if err == nil {
t.Fatal("потеря дельты не сообщена вызывающему")
}
var loss *trafficLossError
if !errors.As(err, &loss) {
t.Fatalf("потеря сообщена не как величина: %v", err)
}
if loss.lost != 1 {
t.Fatalf("учтено %d потерь, ожидалась 1", loss.lost)
}
if !strings.Contains(err.Error(), "1") {
t.Errorf("сообщение не называет количество: %q", err.Error())
}
// Известный пир при этом обязан получить свою дельту: потеря одной записи
// не отменяет остальных.
peer := snapshotPeers(t)["alpha1"]
if *peer.DownloadBytes != 10 || *peer.UploadBytes != 20 {
t.Fatalf("дельта известного пира не записана: %d/%d", *peer.DownloadBytes, *peer.UploadBytes)
}
}
// Мнение systemd на цикл учёта не влияет.
//
// Прежний гейт `if !Hysteria2IsRunning() { return }` стоял на решении о
// применении операции, а util.Exec не отличает «служба неактивна» от
// «спросить не удалось»: сломанный systemctl при живой Hysteria молча отключал
// и учёт трафика, и принудительное отключение — без единой строки в журнале.
func TestCronRunsWhenSystemdSaysStopped(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
withHysteriaRunning(t, false)
id := seedPeer(t, "alpha1", "alpha-auth-id")
peerUsage(t, id, map[string]interface{}{"disabled": int64(1)})
CronHandleAccount()
if got := stub.kicked(); len(got) != 1 {
t.Fatalf("мнение systemd отключило принудительное отключение: %v", got)
}
}
// Отсутствующее значение секрета Traffic Stats API — отказ джобы, а не паника.
//
// Раньше здесь стояло `*trafficSecretConfig.Value` без проверки, причём внутри
// отсоединённой горутины: разыменование nil роняло бы весь процесс вместе с
// обработчиком machine-auth, а не одну джобу.
func TestCronSurvivesMissingTrafficSecret(t *testing.T) {
newTestDB(t)
startAccountStats(t, nil)
seedPeer(t, "alpha1", "alpha-auth-id")
// Пустое значение ключа неотличимо от его отсутствия: и то и другое
// означает «секрета нет». Прежний путь читал `*config.Value` без проверки
// и на строке без значения падал с nil-разыменованием.
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, ""); err != nil {
t.Fatalf("не удалось стереть секрет: %v", err)
}
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("отсутствующий секрет уронил джобу учёта: %v", recovered)
}
}()
CronHandleAccount()
}
// Строка пира без идентификатора не роняет сброс трафика: раньше `*item.Id`
// разыменовывался без проверки.
func TestCronResetTrafficSurvivesRowWithoutID(t *testing.T) {
newTestDB(t)
id := seedPeer(t, "alpha1", "alpha-auth-id")
peerUsage(t, id, map[string]interface{}{"download_bytes": int64(100), "upload_bytes": int64(200)})
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("сброс трафика упал: %v", recovered)
}
}()
CronResetTraffic()
peer := snapshotPeers(t)["alpha1"]
if *peer.DownloadBytes != 0 || *peer.UploadBytes != 0 {
t.Fatalf("счётчики не сброшены: %d/%d", *peer.DownloadBytes, *peer.UploadBytes)
}
}
// Отказ `/traffic` не отменяет enforcement: политика применяется по уже
// известным счётчикам, а не пропускается вместе со сбором.
func TestCronEnforcesEvenWhenTrafficCollectionFails(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{
trafficStatus: http.StatusInternalServerError,
online: map[string]int64{"alpha-auth-id": 1},
})
id := seedPeer(t, "alpha1", "alpha-auth-id")
peerUsage(t, id, map[string]interface{}{"disabled": int64(1)})
CronHandleAccount()
if got := stub.kicked(); len(got) != 1 {
t.Fatalf("отказ сбора трафика отменил принудительное отключение: %v", got)
}
}
// Второй тик поверх идущего цикла не запускает второй цикл. Проверяется
// наблюдаемым следствием: при удерживаемом мьютексе джоба обязана вернуться,
// не сходив в Hysteria ни разу.
func TestCronHandleAccountSkipsOverlappingTick(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, nil)
seedPeer(t, "alpha1", "alpha-auth-id")
accountJobMutex.Lock()
CronHandleAccount()
accountJobMutex.Unlock()
stub.mu.Lock()
calls := stub.trafficCalls + stub.onlineCalls
stub.mu.Unlock()
if calls != 0 {
t.Fatalf("параллельный тик запустил второй цикл учёта: обращений %d", calls)
}
// А после освобождения обычный тик проходит.
CronHandleAccount()
stub.mu.Lock()
calls = stub.trafficCalls + stub.onlineCalls
stub.mu.Unlock()
if calls == 0 {
t.Fatal("цикл учёта не выполнился после освобождения мьютекса")
}
}
// Джоба СИНХРОННА: планировщик обязан видеть её работу, иначе StopCron
// возвращается, releaseResource закрывает SQLite, а недобитые горутины
// продолжают писать в закрытое соединение.
//
// Доказывается тем, что к моменту возврата CronHandleAccount вся работа уже
// сделана — при отсоединённых горутинах обращения к Hysteria к этому моменту
// ещё не случились бы.
func TestCronHandleAccountIsSynchronous(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{
traffic: map[string]bo.Hysteria2UserTraffic{"alpha-auth-id": {Rx: 10, Tx: 20}},
online: map[string]int64{"alpha-auth-id": 1},
})
seedPeer(t, "alpha1", "alpha-auth-id")
CronHandleAccount()
stub.mu.Lock()
trafficCalls := stub.trafficCalls
onlineCalls := stub.onlineCalls
stub.mu.Unlock()
if trafficCalls != 1 || onlineCalls != 1 {
t.Fatalf("работа не завершена к возврату джобы: /traffic %d, /online %d", trafficCalls, onlineCalls)
}
// И записанная дельта уже видна: значит цикл дошёл до конца, а не был
// передан горутине.
peer := snapshotPeers(t)["alpha1"]
if *peer.DownloadBytes != 10 || *peer.UploadBytes != 20 {
t.Fatalf("дельта не записана к возврату джобы: %d/%d", *peer.DownloadBytes, *peer.UploadBytes)
}
}
// StopCron дожидается запущенной джобы учёта. Раньше внешняя горутина
// заканчивалась мгновенно, и планировщику было нечего ждать.
func TestStopCronWaitsForAccountJob(t *testing.T) {
newTestDB(t)
startAccountStats(t, nil)
seedPeer(t, "alpha1", "alpha-auth-id")
// Джоба удерживается занятым мьютексом: пока он не освобождён, ни один
// цикл учёта не идёт, и StopCron обязан вернуться без ожидания.
done := make(chan struct{})
go func() {
defer close(done)
accountJobMutex.Lock()
defer accountJobMutex.Unlock()
time.Sleep(50 * time.Millisecond)
}()
if err := InitCron(); err != nil {
t.Fatalf("InitCron: %v", err)
}
StopCron()
<-done
if count := CronEntryCount(); count != 0 {
t.Fatalf("после остановки осталось %d записей", count)
}
}