П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
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 працює справжній.
640 lines
26 KiB
Go
640 lines
26 KiB
Go
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:00–01: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:00–05: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("вигаданий часовий пояс приймається")
|
||
}
|
||
}
|