Netpulse_SasS/server/internal/store/billing_limits_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

146 lines
7.5 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 (
"errors"
"fmt"
"testing"
"github.com/jackc/pgx/v5/pgconn"
)
// Розпізнавання відмови через стелю — без бази.
//
// ЧОМУ ЦЕ ОКРЕМА ПЕРЕВІРКА, А НЕ ЧАСТИНА ТЕСТУ ПРОТИ БАЗИ
//
// Тому що зламана тут ланка була саме такою: перевірка в БД працювала
// бездоганно, а Go не впізнавав її відмову. httpapi/groups.go шукав у
// тексті помилки слово «ліміт», а тригер 0009 писав англійською — і
// збігу не було ніколи. Тобто в мить, коли стеля вперше спрацювала б,
// людина отримала б 500 «внутрішня помилка» замість пояснення.
//
// Тест проти бази цього НЕ ловить: там відмова справді сталася, рядок
// не створився, перевірка зелена. Ловить лише окрема перевірка того, що
// саме код зробив із цією відмовою далі.
//
// Тому нижче — не «стеля спрацювала», а «відмову впізнано, і з неї
// дістали числа, які можна показати людині».
// planLimitPgError збирає відмову рівно такою, якою її підіймає
// bill.deny_limit із 0069.
func planLimitPgError(msg, detail string) *pgconn.PgError {
return &pgconn.PgError{
Severity: "ERROR",
Code: "23514", // check_violation
Message: msg,
Detail: detail,
Hint: "upgrade_plan",
}
}
func TestAsPlanLimitReadsNumbers(t *testing.T) {
raw := planLimitPgError(
"у тарифі Free дозволено 15 хостів, зараз 15",
`{"limit":"devices","plan":"free","allowed":15,"used":15}`)
// Обгортка навмисно подвійна: у житті помилка проходить через
// fmt.Errorf у CreateDevice і ще раз через шар транзакції.
err := fmt.Errorf("створення хоста: %w", fmt.Errorf("tx: %w", raw))
lim, ok := AsPlanLimit(err)
if !ok {
t.Fatal("відмову через стелю не впізнано в обгортках")
}
if lim.Kind != "devices" || lim.Plan != "free" || lim.Allowed != 15 || lim.Used != 15 {
t.Fatalf("числа не дістались: %+v", lim)
}
if lim.Error() != "у тарифі Free дозволено 15 хостів, зараз 15" {
t.Fatalf("текст відмови загублено: %q", lim.Error())
}
// Типізована помилка відповідає наявному вартовому — на це
// спирається шлях правки мапи, де відмову перекладає mapPgError.
if !errors.Is(lim, ErrPlanLimit) {
t.Fatal("errors.Is(PlanLimitError, ErrPlanLimit) не спрацював")
}
// А СИРА помилка бази вартовому не відповідає, і це не недогляд, а
// факт, який мусить бути записаний перевіркою: у її ланцюжку лежить
// *pgconn.PgError, і жоден errors.Is його з ErrPlanLimit не зведе.
// Саме тому спільний обробник помилок питає AsPlanLimit окремим
// рядком. Якби хтось «спростив» його назад до одного errors.Is,
// відмова через стелю знову перетворилась би на 500 — на всіх
// шляхах, крім мап, і мовчки.
if errors.Is(err, ErrPlanLimit) {
t.Fatal("сира помилка бази раптом відповідає ErrPlanLimit — " +
"перевірте, чи не зайвий тепер AsPlanLimit у writeStoreError")
}
}
// Регресія на ту саму поломку, з якої почалась ця робота.
//
// Стара перевірка шукала слово «ліміт» у тексті. Відмова англійською —
// саме те, що підіймала 0009, і саме те, що вона пропускала. Тепер
// впізнавання йде за HINT, тобто текст може бути будь-яким, включно з
// таким, який ще не переклали.
func TestAsPlanLimitDoesNotDependOnWording(t *testing.T) {
raw := planLimitPgError("device limit reached for tenant abc (limit 15)", "")
lim, ok := AsPlanLimit(fmt.Errorf("створення хоста: %w", raw))
if !ok {
t.Fatal("англійська відмова 0009 не впізнана — та сама поломка, що й була")
}
// Чисел немає — стара міграція їх не передавала. Відмова від цього
// не перестає бути відмовою: 402 має поїхати в будь-якому разі.
if lim.Allowed != 0 || lim.Used != 0 {
t.Fatalf("узялись числа, яких у відмові не було: %+v", lim)
}
if lim.Error() == "" {
t.Fatal("текст відмови порожній — людині нічого показати")
}
}
// Перевірка протилежного боку: сторонні помилки бази не мають
// перетворюватись на «вичерпано тариф».
//
// Це те, що ламається тихо: обробник, який на будь-яку помилку віддає
// 402 «змініть тариф», сховає справжню поломку за пропозицією заплатити,
// і розбиратись у ній ніхто не піде.
func TestAsPlanLimitIgnoresOtherErrors(t *testing.T) {
cases := []struct {
name string
err error
}{
{"звичайна помилка", errors.New("щось пішло не так")},
{"nil", nil},
{"порушення унікальності", fmt.Errorf("x: %w", &pgconn.PgError{
Code: "23505", Message: "duplicate key value violates unique constraint",
})},
{"check без підказки", fmt.Errorf("x: %w", &pgconn.PgError{
Code: "23514", Message: "new row violates check constraint",
})},
{"чужа підказка", fmt.Errorf("x: %w", &pgconn.PgError{
Code: "23514", Message: "щось", Hint: "GRANT SELECT ...",
})},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, ok := AsPlanLimit(c.err); ok {
t.Fatal("сторонню помилку прийнято за вичерпаний тариф")
}
})
}
}
// Зіпсована подробиця не має губити саму відмову.
//
// Інсталяція зі свіжим Go й старою схемою (0069 ще не накотилась) —
// звичайний стан під час оновлення, і в ньому DETAIL порожній або
// чужого формату. Втратити через це 402 означало б показати «внутрішню
// помилку» саме тоді, коли причина відома точно.
func TestAsPlanLimitSurvivesBrokenDetail(t *testing.T) {
for _, detail := range []string{"", "не json", `{"allowed":"багато"}`, `[1,2,3]`} {
err := fmt.Errorf("x: %w", planLimitPgError("стеля", detail))
if _, ok := AsPlanLimit(err); !ok {
t.Fatalf("відмову втрачено через подробицю %q", detail)
}
}
}