Netpulse_SasS/server/internal/store/sla_test.go
byrsapty ca143a616b
Some checks failed
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 59s
CI / server (push) Failing after 3m27s
CI / agent (push) Successful in 3m3s
Білінг, SLA, вбудовані правила, пісочниця установника, тести сторінок
П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.

0069 БІЛІНГ. Аудит 0009 показав, що перевірка ліміту не спрацювала б
жодного разу: isPlanLimit шукала слово «ліміт», а тригер писав
"device limit reached" англійською. Перше ж досягнення стелі дало б
клієнту 500 замість пояснення. Плюс три діри: тригер лише на INSERT
(стеля в 15 обходилась за чотири дії через архів), max_maps/max_agents/
max_users не перевіряло ніщо — тобто рівно те, чим відрізняються плани,
і license_keys була закрита політикою tenant_isolation з 0011, хоча
tenant_id там NULLABLE навмисно: головний сценарій self-hosted був
недосяжний.

Після закінчення ліцензії не вимикається нічого — замерзає лише ріст.
Моніторинг, що перестав моніторити через несплачений рахунок, це
аварія в мережі клієнта, спричинена нами.

0070 SLA. Джерелом обрано ts.icmp_1h, а не device_status_history:
остання не вміє сказати «ми не знали» — перехід пишеться лише при
зміні стану, тож доба мовчання зонда виглядає як доба роботи. Час
розкладено на чотири частини, і «немає даних» не додається ні до чого;
замість вибору між двома брехнями звіт каже, яку частку періоду він
бачив. Закритий період тримає тригер, а не домовленість у Go.

0071 ВІДПОВІДНІСТЬ. 20 правил, кожне прив'язане до родини: об'єднаний
вираз, що покриває Cisco й не покриває MikroTik, дав би «0 порушень» і
сховав сліпу пляму. Вендор не входить у перелік, доки для нього немає
зразка конфігу в тесті. TestBuiltinRulesAreNotAlwaysGreen вимагає, щоб
у кожного правила був конфіг, де воно спрацювало, І де ні.

ПІСОЧНИЦЯ УСТАНОВНИКА — та сама установка в ізольованому проєкті
compose. Знайшла дві справжні вади з трьох спроб:
  * healthcheck бази ходив unix-сокетом, а споживачі по TCP. При
    первинній ініціалізації Postgres слухає лише сокет — compose
    вважав базу здоровою, migrate отримував connection refused. На
    створеній базі цієї фази немає, тож вада чекала на першого клієнта;
  * у білому переліку модулів API не було traps і filecfg — зонд із
    приймачем трапів неможливо було зареєструвати взагалі.

ТЕСТИ СТОРІНОК: 137 → 252. Мережевий шар, права доступу, незворотні
дії, фільтри з адресного рядка. Підмінюється лише fetch і WebSocket —
api/client.ts працює справжній.
2026-08-27 21:17:23 +03:00

640 lines
26 KiB
Go
Raw Permalink 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 store
import (
"strings"
"testing"
"time"
)
// Перевірки розрахунку доступності.
//
// ЩО САМЕ ТУТ ПЕРЕВІРЯЄТЬСЯ, І ЧОМУ САМЕ ЦЕ
//
// Не «формула ділить правильно» — вона ділить правильно з першого разу.
// Перевіряються МЕЖІ, тобто ті випадки, у яких звіт мовчки бреше замість
// того, щоб упасти:
//
// - період, у якому взагалі немає даних;
// - період, частина якого лежить за строком зберігання;
// - хост, заведений посеред періоду;
// - хост, видалений посеред періоду;
// - вікно обслуговування на межі доби.
//
// Спільна риса всіх п'яти: жоден із них не дає помилки. Кожен дає число,
// і саме тому їх треба перевіряти числом, а не оком.
func slaTime(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
panic(err)
}
return t
}
// slaHours робить рівний ряд відер: n годин від start, по samples спроб,
// з яких down невдалих.
func slaHours(start time.Time, n int, samples, down int64) []SLAHourBucket {
out := make([]SLAHourBucket, 0, n)
for i := 0; i < n; i++ {
out = append(out, SLAHourBucket{
Start: start.Add(time.Duration(i) * time.Hour),
Samples: samples,
Down: down,
})
}
return out
}
// Базовий випадок, від якого рахуються всі інші: доба, повний ряд відер,
// жодної втрати.
func TestSLAFullDayNoLoss(t *testing.T) {
from := slaTime("2026-04-01T00:00:00Z")
out := ComputeSLA(SLAInput{
From: from,
To: from.AddDate(0, 0, 1),
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
Buckets: slaHours(from, 24, 120, 0),
TargetPct: 99.9,
MinCoveragePct: 95,
})
if out.ClockSec != 86400 {
t.Fatalf("годинник: %d, очікували 86400", out.ClockSec)
}
if out.UpSec != 86400 || out.DownSec != 0 || out.UnknownSec != 0 {
t.Fatalf("розклад часу: up=%d down=%d unknown=%d", out.UpSec, out.DownSec, out.UnknownSec)
}
if out.UptimePct != 100 || out.CoveragePct != 100 {
t.Fatalf("доступність %v при покритті %v", out.UptimePct, out.CoveragePct)
}
if out.Insufficient || out.Breached {
t.Fatal("повна доба без втрат не може бути ні порушенням, ні невідомістю")
}
}
// МЕЖА 1: даних немає ЖОДНИХ.
//
// Найдорожчий випадок у всій задачі. Наївний розрахунок дає тут 100% —
// «серед вимірів не було невдалих», — і саме цей звіт роздрукують, бо
// він гарний. Правильна відповідь — «не знаємо», і вона не має жодного
// шансу зійти за «виконано».
func TestSLAEmptyPeriodIsNotHundredPercent(t *testing.T) {
from := slaTime("2026-04-01T00:00:00Z")
out := ComputeSLA(SLAInput{
From: from,
To: from.AddDate(0, 0, 1),
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
TargetPct: 99.9,
MinCoveragePct: 95,
})
if out.UptimePct != 0 {
t.Fatalf("доступність без жодного виміру має лишитись нулем, а не %v", out.UptimePct)
}
if out.CoveragePct != 0 {
t.Fatalf("покриття: %v, очікували 0", out.CoveragePct)
}
if out.UnknownSec != 86400 {
t.Fatalf("невідомий час: %d, очікували цілу добу", out.UnknownSec)
}
if out.UpSec != 0 {
t.Fatalf("порожній період дав %d секунд доступності — саме та помилка, від якої все це написано", out.UpSec)
}
if !out.Insufficient {
t.Fatal("період без даних мусить бути позначений як недостатній")
}
if out.Breached {
t.Fatal("недостатність даних — це не порушення SLA")
}
if v := SLAVerdict(out); v != "недостатньо даних" {
t.Fatalf("вердикт %q замість «недостатньо даних»", v)
}
}
// Половина доби без даних: покриття 50%, і вердикту немає.
//
// Тут і видно, навіщо покриття окремим числом. Доступність із виміряної
// половини — усі 100%, і без порога вона поїхала б у звіт як «виконано».
func TestSLAHalfSilenceBlocksVerdict(t *testing.T) {
from := slaTime("2026-04-01T00:00:00Z")
out := ComputeSLA(SLAInput{
From: from,
To: from.AddDate(0, 0, 1),
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
Buckets: slaHours(from, 12, 120, 0),
TargetPct: 99.9,
MinCoveragePct: 95,
})
if out.CoveragePct != 50 {
t.Fatalf("покриття: %v, очікували 50", out.CoveragePct)
}
if out.UptimePct != 100 {
t.Fatalf("доступність із виміряного: %v, очікували 100", out.UptimePct)
}
if !out.Insufficient {
t.Fatal("покриття 50% нижче за поріг 95% — вердикту бути не має")
}
if out.UnknownSec != 43200 {
t.Fatalf("невідомий час: %d, очікували півдоби", out.UnknownSec)
}
}
// Втрати всередині години розкладаються часткою спроб.
func TestSLAPartialLossInsideHour(t *testing.T) {
from := slaTime("2026-04-01T00:00:00Z")
buckets := slaHours(from, 24, 120, 0)
// Одна година: чверть спроб не відповіла — це 15 хвилин простою.
buckets[3].Down = 30
out := ComputeSLA(SLAInput{
From: from,
To: from.AddDate(0, 0, 1),
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
Buckets: buckets,
TargetPct: 99.9,
MinCoveragePct: 95,
})
if out.DownSec != 900 {
t.Fatalf("простій: %d с, очікували 900", out.DownSec)
}
if out.UpSec+out.DownSec+out.UnknownSec+out.MaintenanceSec != out.ClockSec {
t.Fatalf("частини не сходяться з цілим: %d+%d+%d+%d != %d",
out.UpSec, out.DownSec, out.UnknownSec, out.MaintenanceSec, out.ClockSec)
}
if !out.Breached {
t.Fatalf("15 хвилин простою за добу — це %v%%, нижче за ціль 99.9", out.UptimePct)
}
}
// МЕЖА 2: хост заведено посеред періоду.
//
// Без цієї межі новий хост, який з'явився 20 числа, отримав би 19 діб
// «недоступності» — і зіпсував би місяць усій групі рівно тим, що його
// завели.
func TestSLADeviceCreatedMidPeriod(t *testing.T) {
from := slaTime("2026-04-01T00:00:00Z")
to := from.AddDate(0, 1, 0)
created := slaTime("2026-04-21T00:00:00Z")
out := ComputeSLA(SLAInput{
From: from,
To: to,
CreatedAt: created,
// Дані є рівно з дня заведення й до кінця місяця: 10 діб.
Buckets: slaHours(created, 10*24, 120, 0),
TargetPct: 99.9,
MinCoveragePct: 95,
})
if want := int64(10 * 86400); out.ClockSec != want {
t.Fatalf("годинник: %d, очікували %d (лише з дня заведення)", out.ClockSec, want)
}
if out.UnknownSec != 0 {
t.Fatalf("до заведення хоста часу бути не має, а є %d с невідомого", out.UnknownSec)
}
if out.CoveragePct != 100 || out.UptimePct != 100 {
t.Fatalf("покриття %v, доступність %v — очікували по 100", out.CoveragePct, out.UptimePct)
}
if out.Insufficient {
t.Fatal("десять повністю виміряних діб — це достатньо для вердикту")
}
}
// МЕЖА 3: хост видалено посеред періоду.
//
// Дзеркальна помилка: без звуження годинника архівований 10 числа хост
// показав би 20 діб мовчання й потягнув би покриття групи вниз — при
// тому що після 10 числа його просто не існувало.
func TestSLADeviceDeletedMidPeriod(t *testing.T) {
from := slaTime("2026-04-01T00:00:00Z")
to := from.AddDate(0, 1, 0)
deleted := slaTime("2026-04-11T00:00:00Z")
out := ComputeSLA(SLAInput{
From: from,
To: to,
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
DeletedAt: &deleted,
Buckets: slaHours(from, 10*24, 120, 0),
TargetPct: 99.9,
MinCoveragePct: 95,
})
if want := int64(10 * 86400); out.ClockSec != want {
t.Fatalf("годинник: %d, очікували %d (лише до видалення)", out.ClockSec, want)
}
if out.CoveragePct != 100 {
t.Fatalf("покриття: %v, очікували 100", out.CoveragePct)
}
}
// Хоста в періоді не існувало зовсім — рядок є, чисел немає.
func TestSLADeviceOutsidePeriodEntirely(t *testing.T) {
from := slaTime("2026-04-01T00:00:00Z")
out := ComputeSLA(SLAInput{
From: from,
To: from.AddDate(0, 1, 0),
CreatedAt: slaTime("2026-06-01T00:00:00Z"),
TargetPct: 99.9,
MinCoveragePct: 95,
})
if out.ClockSec != 0 || out.UnknownSec != 0 {
t.Fatalf("хоста не було: годинник %d, невідомо %d — очікували нулі",
out.ClockSec, out.UnknownSec)
}
if !out.Insufficient || out.Breached {
t.Fatal("неіснуючий хост не порушує SLA й не виконує його")
}
}
// МЕЖА 4: вікно обслуговування на межі доби.
//
// Вікно 23:0001:00 не належить жодній добі цілком. Дві типові помилки:
// приписати його одній добі повністю (тоді друга доба псується) або
// відкинути як «не вміщається» (тоді псуються обидві).
func TestSLAMaintenanceAcrossMidnight(t *testing.T) {
from := slaTime("2026-04-02T00:00:00Z")
to := from.AddDate(0, 0, 1)
buckets := slaHours(from, 24, 120, 0)
// Перша година доби — суцільна недоступність, але вона накрита
// вікном обслуговування, що почалось учора о 23:00.
buckets[0].Down = 120
out := ComputeSLA(SLAInput{
From: from,
To: to,
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
Maintenance: []SLASpan{{
From: slaTime("2026-04-01T23:00:00Z"),
To: slaTime("2026-04-02T01:00:00Z"),
}},
Buckets: buckets,
TargetPct: 99.9,
MinCoveragePct: 95,
})
if out.MaintenanceSec != 3600 {
t.Fatalf("обслуговування: %d с, очікували 3600 — у цю добу потрапила лише його половина",
out.MaintenanceSec)
}
if out.DownSec != 0 {
t.Fatalf("простій під вікном обслуговування не рахується, а нарахувало %d с", out.DownSec)
}
if out.UptimePct != 100 {
t.Fatalf("доступність: %v, очікували 100", out.UptimePct)
}
// Годинник зупинявся, тому знаменник покриття — 23 години, а не 24.
if out.CoveragePct != 100 {
t.Fatalf("покриття: %v — вікно має виходити й зі знаменника теж", out.CoveragePct)
}
if out.UpSec+out.DownSec+out.UnknownSec+out.MaintenanceSec != out.ClockSec {
t.Fatal("частини не сходяться з цілим")
}
}
// Вікна, що перетинаються, не рахуються двічі.
func TestSLAOverlappingMaintenanceCountedOnce(t *testing.T) {
from := slaTime("2026-04-02T00:00:00Z")
out := ComputeSLA(SLAInput{
From: from,
To: from.AddDate(0, 0, 1),
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
Maintenance: []SLASpan{
{From: slaTime("2026-04-02T02:00:00Z"), To: slaTime("2026-04-02T04:00:00Z")},
{From: slaTime("2026-04-02T03:00:00Z"), To: slaTime("2026-04-02T05:00:00Z")},
},
Buckets: slaHours(from, 24, 120, 0),
TargetPct: 99.9,
MinCoveragePct: 95,
})
if out.MaintenanceSec != 3*3600 {
t.Fatalf("обслуговування: %d с, очікували 10800 (02:0005:00 без подвійного рахунку)",
out.MaintenanceSec)
}
}
// Обслуговування не має ПОКРАЩУВАТИ звіт.
//
// Спокуслива помилка: зарахувати вікно як доступність. Тоді місяць, у
// якому була година аварії й десять годин планових робіт, вийшов би
// кращим за місяць без робіт узагалі.
func TestSLAMaintenanceDoesNotImproveUptime(t *testing.T) {
from := slaTime("2026-04-02T00:00:00Z")
buckets := slaHours(from, 24, 120, 0)
buckets[10].Down = 120 // година справжньої аварії
base := SLAInput{
From: from,
To: from.AddDate(0, 0, 1),
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
Buckets: buckets,
TargetPct: 99,
MinCoveragePct: 95,
}
without := ComputeSLA(base)
base.Maintenance = []SLASpan{{
From: slaTime("2026-04-02T20:00:00Z"),
To: slaTime("2026-04-02T22:00:00Z"),
}}
with := ComputeSLA(base)
if with.UptimePct > without.UptimePct {
t.Fatalf("планові роботи покращили звіт: %v проти %v", with.UptimePct, without.UptimePct)
}
if with.DownSec != without.DownSec {
t.Fatalf("аварія поза вікном змінилась через вікно: %d проти %d",
with.DownSec, without.DownSec)
}
}
// Смуги недоступності: година мовчання посеред аварії не ділить її надвоє.
func TestSLAIncidentsSurviveDataGap(t *testing.T) {
from := slaTime("2026-04-02T00:00:00Z")
buckets := slaHours(from, 24, 120, 0)
buckets[5].Down = 120
buckets[7].Down = 120
// Шоста година — без даних узагалі: відро прибрано.
buckets = append(buckets[:6], buckets[7:]...)
out := ComputeSLA(SLAInput{
From: from,
To: from.AddDate(0, 0, 1),
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
Buckets: buckets,
TargetPct: 99.9,
MinCoveragePct: 50,
})
if out.Incidents != 1 {
t.Fatalf("смуг недоступності: %d, очікували 1 — діра в даних не є відновленням",
out.Incidents)
}
}
// Дві аварії, розділені справді робочою годиною, — це дві смуги.
func TestSLAIncidentsSplitByGoodHour(t *testing.T) {
from := slaTime("2026-04-02T00:00:00Z")
buckets := slaHours(from, 24, 120, 0)
buckets[5].Down = 120
buckets[7].Down = 120
out := ComputeSLA(SLAInput{
From: from,
To: from.AddDate(0, 0, 1),
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
Buckets: buckets,
TargetPct: 99.9,
MinCoveragePct: 50,
})
if out.Incidents != 2 {
t.Fatalf("смуг недоступності: %d, очікували 2", out.Incidents)
}
}
// Відро з нулем спроб — це мовчання, а не «втрат не було».
func TestSLAZeroSampleBucketIsSilence(t *testing.T) {
from := slaTime("2026-04-02T00:00:00Z")
out := ComputeSLA(SLAInput{
From: from,
To: from.Add(2 * time.Hour),
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
Buckets: []SLAHourBucket{{Start: from, Samples: 0}},
TargetPct: 99.9,
MinCoveragePct: 95,
})
if out.UpSec != 0 {
t.Fatalf("порожнє відро дало %d с доступності", out.UpSec)
}
if out.UnknownSec != 7200 {
t.Fatalf("невідомий час: %d, очікували 7200", out.UnknownSec)
}
}
// ---------------------------------------------------------------------
// Календар
// ---------------------------------------------------------------------
// Квартал ріжеться в поясі цілі, а не в UTC.
//
// Різниця — три години на кожній межі. Аварія о 01:00 1 липня за
// київським часом належить третьому кварталу; порахована в UTC, вона
// потрапила б у другий, тобто у ВЖЕ ЗАКРИТИЙ звіт.
func TestSLAQuarterBoundsUseTargetTimezone(t *testing.T) {
kyiv, err := time.LoadLocation("Europe/Kyiv")
if err != nil {
t.Skip("у системі немає бази часових поясів")
}
span, err := SLAPeriodBounds(SLAPeriodQuarterly, slaTime("2026-05-15T10:00:00Z"), kyiv)
if err != nil {
t.Fatal(err)
}
if got := span.From.In(kyiv).Format(time.RFC3339); got != "2026-04-01T00:00:00+03:00" {
t.Fatalf("початок кварталу: %s", got)
}
if got := span.To.In(kyiv).Format(time.RFC3339); got != "2026-07-01T00:00:00+03:00" {
t.Fatalf("кінець кварталу: %s", got)
}
// Межі напіввідкриті: 1 липня о 00:00 належить уже наступному
// кварталу, а не обом одразу.
next, err := SLAPeriodBounds(SLAPeriodQuarterly, span.To, kyiv)
if err != nil {
t.Fatal(err)
}
if !next.From.Equal(span.To) {
t.Fatalf("між кварталами щілина або нахлест: %s проти %s", next.From, span.To)
}
}
// Місяць рахується календарем, а не «тридцятьма добами».
func TestSLAMonthBoundsFollowCalendar(t *testing.T) {
span, err := SLAPeriodBounds(SLAPeriodMonthly, slaTime("2026-02-14T12:00:00Z"), time.UTC)
if err != nil {
t.Fatal(err)
}
if got := span.To.Sub(span.From).Hours() / 24; got != 28 {
t.Fatalf("лютий 2026 вийшов %v діб", got)
}
}
// Попередній період — це попередній календарний, а не «мінус довжина».
//
// Різниця видно на січні: віднявши 31 добу від 1 березня, отримаєш
// 29 січня, тобто взагалі не той місяць.
func TestSLAPrevPeriodCrossesYear(t *testing.T) {
span, err := SLAPrevPeriod(SLAPeriodMonthly, slaTime("2026-01-10T00:00:00Z"), time.UTC)
if err != nil {
t.Fatal(err)
}
if got := span.From.Format("2006-01-02"); got != "2025-12-01" {
t.Fatalf("попередній місяць до січня 2026: %s", got)
}
}
// Тиждень починається понеділком, і неділя належить попередньому.
func TestSLAWeekStartsOnMonday(t *testing.T) {
// 2026-04-05 — неділя.
span, err := SLAPeriodBounds(SLAPeriodWeekly, slaTime("2026-04-05T12:00:00Z"), time.UTC)
if err != nil {
t.Fatal(err)
}
if got := span.From.Format("2006-01-02"); got != "2026-03-30" {
t.Fatalf("початок тижня для неділі 5 квітня: %s, очікували 2026-03-30", got)
}
}
func TestSLAUnknownPeriodKindIsRefused(t *testing.T) {
if _, err := SLAPeriodBounds("yearly", time.Now(), time.UTC); err == nil {
t.Fatal("невідомий тип періоду мусить бути помилкою, а не порожнім звітом")
}
}
// ---------------------------------------------------------------------
// Горизонт зберігання
// ---------------------------------------------------------------------
// МЕЖА 5: період частково лежить за строком зберігання.
//
// Найтихіша з усіх помилок. Такий період виглядає точнісінько як період,
// у якому півмережі не опитувалось: те саме низьке покриття, ті самі
// невідомі секунди. Різницю знає лише ця умова.
func TestSLABeyondHorizonDetected(t *testing.T) {
oldest := slaTime("2026-05-10T00:00:00Z")
h := SLAHorizon{Relation: "ts.icmp_1h", Oldest: &oldest}
if !slaBeyondHorizon(h, slaTime("2026-04-01T00:00:00Z")) {
t.Fatal("квартал, що починається до найстарішого відра, має підняти попередження")
}
if slaBeyondHorizon(h, slaTime("2026-06-01T00:00:00Z")) {
t.Fatal("період усередині горизонту попередження піднімати не має")
}
}
// Порожня база — це не «за межею горизонту».
//
// Різниця важлива: у першому випадку дані видалили, у другому їх ще не
// збирали. Порада людині в цих двох випадках протилежна.
func TestSLAEmptyDatabaseIsNotBeyondHorizon(t *testing.T) {
if slaBeyondHorizon(SLAHorizon{Relation: "ts.icmp_1h"}, time.Now()) {
t.Fatal("база без жодного відра не має скаржитись на строк зберігання")
}
}
// ---------------------------------------------------------------------
// Підсумок і вивантаження
// ---------------------------------------------------------------------
// Підсумок групи рахується з секунд, а не як середнє відсотків.
//
// Різниця видна саме там, де вона найдорожча: новий хост, що прожив у
// періоді годину й ту пролежав, у середньому відсотків важить стільки ж,
// скільки хост, що пропрацював цілий місяць.
func TestSLATotalsWeighByTime(t *testing.T) {
rows := []SLARow{
{DeviceName: "старий", SLAOutcome: SLAOutcome{
ClockSec: 30 * 86400, UpSec: 30 * 86400, CoveragePct: 100, UptimePct: 100,
}},
{DeviceName: "новий", SLAOutcome: SLAOutcome{
ClockSec: 3600, DownSec: 3600, CoveragePct: 100,
}},
}
tot := slaTotals(rows, 99.9, 95)
// Середнє відсотків дало б 50%. Правильна відповідь — 99.86%.
if tot.UptimePct < 99.8 {
t.Fatalf("підсумкова доступність: %v — схоже на середнє відсотків, а не на суму секунд",
tot.UptimePct)
}
if tot.ClockSec != 30*86400+3600 {
t.Fatalf("сумарний годинник: %d", tot.ClockSec)
}
}
// Підсумок групи, у якій нікого не виміряли, теж не є «виконано».
func TestSLATotalsInsufficientWhenNothingMeasured(t *testing.T) {
rows := []SLARow{
{DeviceName: "мовчун", SLAOutcome: SLAOutcome{ClockSec: 86400, UnknownSec: 86400}},
}
tot := slaTotals(rows, 99.9, 95)
if !tot.Insufficient || tot.Breached {
t.Fatalf("група без вимірів: insufficient=%v breached=%v", tot.Insufficient, tot.Breached)
}
}
// У вивантаженні клітинка доступності порожня, а не нульова, коли її не
// виміряли: нуль читається як «лежало весь період».
func TestSLACSVLeavesUptimeBlankWhenUnknown(t *testing.T) {
var b strings.Builder
rep := SLAReport{
Target: SLATarget{Name: "Ядро", TargetPct: 99.9, PeriodKind: SLAPeriodMonthly, TZ: "UTC"},
PeriodFrom: "2026-04-01", PeriodTo: "2026-05-01",
Rows: []SLARow{{DeviceName: "sw-1", SLAOutcome: SLAOutcome{
ClockSec: 86400, UnknownSec: 86400, Insufficient: true,
}}},
Horizon: SLAHorizon{Relation: "ts.icmp_1h"},
}
if err := SLAReportCSV(&b, rep); err != nil {
t.Fatal(err)
}
out := b.String()
if !strings.Contains(out, "sw-1;;0;недостатньо даних") {
t.Fatalf("рядок хоста без вимірів виглядає не так:\n%s", out)
}
if !strings.Contains(out, "без строку") {
t.Fatal("у шапці має стояти строк зберігання джерела")
}
if !strings.Contains(out, "sep=;") {
t.Fatal("без sep=; український Excel розкладе файл в один стовпчик")
}
}
// Ім'я файла не тягне кирилицю в заголовок Content-Disposition.
func TestSLAReportFileNameIsASCII(t *testing.T) {
name := SLAReportFileName(SLAReport{
Target: SLATarget{Name: "Ядро мережі / Київ"},
PeriodFrom: "2026-04-01",
})
for _, r := range name {
if r > 127 {
t.Fatalf("в імені файла лишилась не-ASCII: %q", name)
}
}
if !strings.HasSuffix(name, "-2026-04-01.csv") {
t.Fatalf("ім'я файла без періоду: %q", name)
}
}
// ---------------------------------------------------------------------
// Перевірка цілі
// ---------------------------------------------------------------------
func TestValidateSLATargetRejectsNonsense(t *testing.T) {
base := SLATargetInput{
Name: "Ядро", PeriodKind: SLAPeriodMonthly, TargetPct: 99.9, MinCoveragePct: 95,
}
if err := ValidateSLATarget(base); err != nil {
t.Fatalf("нормальна ціль відхилена: %v", err)
}
bad := base
bad.Name = " "
if err := ValidateSLATarget(bad); err == nil {
t.Error("ціль без назви приймається")
}
bad = base
bad.PeriodKind = "yearly"
if err := ValidateSLATarget(bad); err == nil {
t.Error("невідомий тип періоду приймається")
}
// Нуль — не «без цілі», а «0% доступності». Набирається однією
// помилкою в порожньому полі, після чого жоден звіт ніколи не
// покаже порушення.
bad = base
bad.TargetPct = 0
if err := ValidateSLATarget(bad); err == nil {
t.Error("ціль 0% приймається")
}
bad = base
bad.TZ = "Middle/Earth"
if err := ValidateSLATarget(bad); err == nil {
t.Error("вигаданий часовий пояс приймається")
}
}