Files
worker/app/models/status_page_test.go
Gleb Tv 2c884c5612
Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
refactor: adopt worker module path
2026-07-13 17:56:12 +03:00

402 строки
15 KiB
Go

package models_test
import (
"strings"
"testing"
"time"
"gorm.io/gorm"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"rocketgit.ru/rsmon/worker/app/models"
)
// statusPageSeedAcc returns a fresh account for status-page tests.
func statusPageSeedAcc(t *testing.T) *models.Account {
t.Helper()
acc := &models.Account{Name: "status-page-" + strings.ReplaceAll(t.Name(), "/", "_")}
require.NoError(t, models.DB().Create(acc).Error)
return acc
}
// TestStatusPage_Defaults asserts that creating a StatusPage with the
// minimum required fields fills in the documented defaults (colors,
// history days, soft-delete marker) and leaves IsPublished false.
func TestStatusPage_Defaults(t *testing.T) {
models.Drop()
models.Migrate()
acc := statusPageSeedAcc(t)
page := &models.StatusPage{
AccountID: acc.ID,
Slug: "acme-status",
Name: "Acme Status",
}
require.NoError(t, models.DB().Create(page).Error)
got := models.StatusPage{}
require.NoError(t, models.DB().First(&got, page.ID).Error)
assert.Equal(t, "#62c600", got.PrimaryColor, "default primary color")
assert.Equal(t, "#1a73e8", got.AccentColor, "default accent color")
assert.Equal(t, 90, got.ShowHistoryDays, "default history days")
assert.True(t, got.ShowUptimeBars, "show_uptime_bars defaults true")
assert.True(t, got.ShowResponseTimes, "show_response_times defaults true")
assert.False(t, got.IsPublished, "is_published defaults false")
assert.False(t, got.IsPublishedNow(), "IsPublishedNow() returns false until publish")
assert.True(t, got.DeletedAt.Valid == false, "no soft-delete timestamp on fresh row")
}
// TestStatusPage_SlugUniquenessSoftDelete verifies the partial unique
// index allows recreating a slug after soft-deleting the previous row.
func TestStatusPage_SlugUniquenessSoftDelete(t *testing.T) {
models.Drop()
models.Migrate()
acc := statusPageSeedAcc(t)
first := &models.StatusPage{
AccountID: acc.ID,
Slug: "rollout",
Name: "Rollout",
}
require.NoError(t, models.DB().Create(first).Error)
// Second live row with the same slug must fail.
dup := &models.StatusPage{
AccountID: acc.ID,
Slug: "rollout",
Name: "Rollout copy",
}
err := models.DB().Create(dup).Error
require.Error(t, err, "duplicate slug on live rows must fail")
assert.True(t,
strings.Contains(strings.ToLower(err.Error()), "unique") ||
strings.Contains(strings.ToLower(err.Error()), "duplicate"),
"unexpected error: %v", err,
)
// Soft-delete the first row. After that the slug is reusable.
require.NoError(t, models.DB().Delete(first).Error)
again := &models.StatusPage{
AccountID: acc.ID,
Slug: "rollout",
Name: "Rollout v2",
}
require.NoError(t, models.DB().Create(again).Error,
"recreating a slug after a soft-delete must succeed (partial index)")
assert.NotEqual(t, first.ID, again.ID)
}
// TestStatusPage_SlugIsGlobal verifies public URLs are unambiguous: the
// account is not part of /status/:slug, so another account cannot claim it.
func TestStatusPage_SlugIsGlobal(t *testing.T) {
models.Drop()
models.Migrate()
accA := statusPageSeedAcc(t)
accB := &models.Account{Name: "status-page-other"}
require.NoError(t, models.DB().Create(accB).Error)
a := &models.StatusPage{AccountID: accA.ID, Slug: "shared", Name: "A"}
require.NoError(t, models.DB().Create(a).Error)
b := &models.StatusPage{AccountID: accB.ID, Slug: "shared", Name: "B"}
require.Error(t, models.DB().Create(b).Error,
"different accounts must not be able to reuse a public slug")
}
func TestStatusPageIncidentDeliveryIsIdempotentAndAccountScoped(t *testing.T) {
models.Drop()
models.Migrate()
account := statusPageSeedAcc(t)
page := &models.StatusPage{AccountID: account.ID, Slug: "delivery-page", Name: "Delivery"}
require.NoError(t, models.DB().Create(page).Error)
now := time.Now()
subscriber := &models.StatusPageSubscriber{StatusPageID: page.ID, Email: "subscriber@example.test", Kind: models.StatusPageSubscriberKindAlert, ConfirmedAt: &now}
require.NoError(t, models.DB().Create(subscriber).Error)
incident := &models.StatusPageIncident{StatusPageID: page.ID, Title: "API unavailable", Severity: models.StatusPageIncidentSeverityCrit, StartedAt: now}
require.NoError(t, models.DB().Create(incident).Error)
require.NoError(t, models.DB().Transaction(func(tx *gorm.DB) error {
return models.EnqueueStatusPageIncidentDeliveriesTx(tx, page, incident, "opened")
}))
require.NoError(t, models.DB().Transaction(func(tx *gorm.DB) error {
return models.EnqueueStatusPageIncidentDeliveriesTx(tx, page, incident, "opened")
}))
var deliveries []models.StatusPageDelivery
require.NoError(t, models.DB().Where("idempotency_key LIKE ?", "status-page:opened:%").Find(&deliveries).Error)
require.Len(t, deliveries, 1)
assert.Equal(t, page.ID, deliveries[0].StatusPageID)
}
func TestStatusPageDailyDigestIsTimezoneDateIdempotent(t *testing.T) {
models.Drop()
models.Migrate()
account := statusPageSeedAcc(t)
account.Timezone = "UTC"
require.NoError(t, models.DB().Save(account).Error)
page := &models.StatusPage{AccountID: account.ID, Slug: "digest-page", Name: "Digest"}
require.NoError(t, models.DB().Create(page).Error)
now := time.Date(2026, time.July, 13, 9, 15, 0, 0, time.UTC)
confirmedAt := now.Add(-time.Hour)
subscriber := &models.StatusPageSubscriber{StatusPageID: page.ID, Email: "digest@example.test", Kind: models.StatusPageSubscriberKindDigestDaily, ConfirmedAt: &confirmedAt}
require.NoError(t, models.DB().Create(subscriber).Error)
require.NoError(t, models.DB().Create(&models.StatusPageIncident{StatusPageID: page.ID, Title: "Still open", Severity: models.StatusPageIncidentSeverityWarn, StartedAt: now}).Error)
_ = models.EnqueueStatusPageDailyDigests(now)
_ = models.EnqueueStatusPageDailyDigests(now.Add(30 * time.Second))
var count int64
require.NoError(t, models.DB().Model(&models.StatusPageDelivery{}).Where("idempotency_key LIKE ?", "status-page:digest:%").Count(&count).Error)
assert.GreaterOrEqual(t, count, int64(1), "catch-up may include earlier local due dates")
}
// TestStatusPage_NormalizeSlugAndValidate exercises the slug normalizer
// and validator — uppercase input becomes lowercase, and an invalid
// slug (leading dash) is rejected on save via BeforeSave.
func TestStatusPage_NormalizeSlugAndValidate(t *testing.T) {
models.Drop()
models.Migrate()
acc := statusPageSeedAcc(t)
assert.Equal(t, "lower-case", models.NormalizeStatusPageSlug(" Lower-Case "))
assert.NoError(t, models.ValidateStatusPageSlug("acme-status"))
bad := []string{"", "-leading-dash", "trailing-dash-", "UPPER", "with spaces", "with_underscore"}
for _, slug := range bad {
err := models.ValidateStatusPageSlug(slug)
assert.Error(t, err, "expected error for slug %q", slug)
}
// Save enforces the same rules — uppercase gets normalized, invalid
// characters reject.
good := &models.StatusPage{AccountID: acc.ID, Slug: "ACME-Status", Name: "Acme"}
require.NoError(t, models.DB().Create(good).Error)
assert.Equal(t, "acme-status", good.Slug)
bad2 := &models.StatusPage{AccountID: acc.ID, Slug: "-bad", Name: "Bad"}
err := models.DB().Create(bad2).Error
require.Error(t, err, "invalid slug should be rejected by BeforeSave")
}
// TestStatusPage_ColorValidation rejects non-hex colors at save time.
func TestStatusPage_ColorValidation(t *testing.T) {
models.Drop()
models.Migrate()
acc := statusPageSeedAcc(t)
bad := &models.StatusPage{
AccountID: acc.ID,
Slug: "badcolor",
Name: "BadColor",
PrimaryColor: "not-a-color",
}
err := models.DB().Create(bad).Error
require.Error(t, err, "non-hex color must be rejected")
good := &models.StatusPage{
AccountID: acc.ID,
Slug: "goodcolor",
Name: "GoodColor",
PrimaryColor: "#0a1b2c",
AccentColor: "#abc",
}
require.NoError(t, models.DB().Create(good).Error)
reloaded := models.StatusPage{}
require.NoError(t, models.DB().First(&reloaded, good.ID).Error)
assert.Equal(t, "#0a1b2c", reloaded.PrimaryColor)
assert.Equal(t, "#abc", reloaded.AccentColor)
}
// TestStatusPageSubscriber_UniqueActive verifies the partial unique
// index on (status_page_id, lower(email)) only fires while a
// subscriber is not unsubscribed.
func TestStatusPageSubscriber_UniqueActive(t *testing.T) {
models.Drop()
models.Migrate()
acc := statusPageSeedAcc(t)
page := &models.StatusPage{
AccountID: acc.ID,
Slug: "sub",
Name: "Sub",
}
require.NoError(t, models.DB().Create(page).Error)
mk := func(email string, unsub *time.Time) *models.StatusPageSubscriber {
s := &models.StatusPageSubscriber{
StatusPageID: page.ID,
Email: email,
Kind: models.StatusPageSubscriberKindAlert,
ConfirmTokenHash: models.HashStatusPageToken("tok-" + email),
}
s.UnsubscribedAt = unsub
return s
}
first := mk("Alice@Example.com", nil)
require.NoError(t, models.DB().Create(first).Error)
// Same email with mixed case must conflict (lower(email) is the
// unique key), so a true case-insensitive uniqueness is in place.
dup := mk("alice@example.com", nil)
err := models.DB().Create(dup).Error
require.Error(t, err, "active subscriber with same lowercased email must conflict")
// Soft-unsubscribe the original then re-add a fresh row with the
// same email — this must succeed because the partial index
// excludes unsubscribed rows.
now := time.Now()
first.UnsubscribedAt = &now
require.NoError(t, models.DB().Save(first).Error)
resurrected := mk("alice@example.com", nil)
require.NoError(t, models.DB().Create(resurrected).Error,
"creating a fresh subscriber after the previous one unsubscribed must succeed")
assert.NotEqual(t, first.ID, resurrected.ID)
// ActiveSubscriber reflects both confirmed and unsubscribed flags.
confirmed := time.Now()
active := &models.StatusPageSubscriber{
StatusPageID: page.ID,
Email: "bob@example.com",
Kind: models.StatusPageSubscriberKindAlert,
ConfirmTokenHash: models.HashStatusPageToken("tok-bob"),
ConfirmedAt: &confirmed,
}
require.NoError(t, models.DB().Create(active).Error)
assert.True(t, active.IsActiveSubscriber(), "confirmed and not unsubscribed → active")
pending := &models.StatusPageSubscriber{
StatusPageID: page.ID,
Email: "carol@example.com",
Kind: models.StatusPageSubscriberKindAlert,
ConfirmTokenHash: models.HashStatusPageToken("tok-carol"),
}
require.NoError(t, models.DB().Create(pending).Error)
assert.False(t, pending.IsActiveSubscriber(), "unconfirmed → not active")
}
// TestStatusPageIncident_SeverityAndEventFK checks that the FK to
// events is wired correctly and that severity defaults to "info".
func TestStatusPageIncident_SeverityAndEventFK(t *testing.T) {
models.Drop()
models.Migrate()
acc := statusPageSeedAcc(t)
page := &models.StatusPage{AccountID: acc.ID, Slug: "inc", Name: "Inc"}
require.NoError(t, models.DB().Create(page).Error)
now := time.Now()
inc := &models.StatusPageIncident{
StatusPageID: page.ID,
Title: "API slowdown",
BodyMD: "Investigating.",
Severity: models.StatusPageIncidentSeverityWarn,
StartedAt: now,
}
require.NoError(t, models.DB().Create(inc).Error)
got := models.StatusPageIncident{}
require.NoError(t, models.DB().First(&got, inc.ID).Error)
assert.Equal(t, "warn", got.Severity)
// Default severity is "info" when omitted.
auto := &models.StatusPageIncident{
StatusPageID: page.ID,
Title: "heads up",
StartedAt: now,
}
require.NoError(t, models.DB().Create(auto).Error)
gotAuto := models.StatusPageIncident{}
require.NoError(t, models.DB().First(&gotAuto, auto.ID).Error)
assert.Equal(t, models.StatusPageIncidentSeverityInfo, gotAuto.Severity)
// EventID stays nullable and the FK tolerates a NULL event.
var nilEvt *int64
noEvt := &models.StatusPageIncident{
StatusPageID: page.ID,
Title: "no event",
Severity: models.StatusPageIncidentSeverityCrit,
StartedAt: now,
EventID: nilEvt,
}
require.NoError(t, models.DB().Create(noEvt).Error)
}
// TestStatusPageMaintenance_BigintArray ensures the monitor_ids bigint[]
// column round-trips through the GORM pq.Int64Array driver correctly.
func TestStatusPageMaintenance_BigintArray(t *testing.T) {
models.Drop()
models.Migrate()
acc := statusPageSeedAcc(t)
page := &models.StatusPage{AccountID: acc.ID, Slug: "mt", Name: "Maintenance"}
require.NoError(t, models.DB().Create(page).Error)
now := time.Now()
maint := &models.StatusPageMaintenance{
StatusPageID: page.ID,
Title: "DB upgrade",
Description: "Rolling upgrade.",
StartsAt: now,
EndsAt: now.Add(2 * time.Hour),
MonitorIDs: models.Int64ArrayFromSlice([]int64{1, 7, 42}),
NotifySubscribers: true,
}
require.NoError(t, models.DB().Create(maint).Error)
got := models.StatusPageMaintenance{}
require.NoError(t, models.DB().First(&got, maint.ID).Error)
assert.Equal(t, []int64{1, 7, 42}, []int64(got.MonitorIDs))
assert.True(t, got.NotifySubscribers)
}
// TestStatusPageDomain_TableReservation verifies the M5 table is
// created with the right unique constraints even though M0 does not
// yet populate it.
func TestStatusPageDomain_TableReservation(t *testing.T) {
models.Drop()
models.Migrate()
acc := statusPageSeedAcc(t)
page := &models.StatusPage{AccountID: acc.ID, Slug: "dom", Name: "Domain"}
require.NoError(t, models.DB().Create(page).Error)
d := &models.StatusPageDomain{
StatusPageID: page.ID,
Domain: "status.example.com",
TXTToken: "rsmon-verify=abc123",
}
require.NoError(t, models.DB().Create(d).Error)
require.NoError(t, models.DB().Create(&models.StatusPageDomain{
StatusPageID: page.ID,
Domain: "www.status.example.com",
TXTToken: "rsmon-verify=second",
}).Error, "one status page may own multiple domains")
// Second page claiming the same domain must fail because domain
// is globally unique.
other := &models.StatusPage{AccountID: acc.ID, Slug: "dom2", Name: "Domain2"}
require.NoError(t, models.DB().Create(other).Error)
dup := &models.StatusPageDomain{
StatusPageID: other.ID,
Domain: "status.example.com",
TXTToken: "rsmon-verify=xyz789",
}
err := models.DB().Create(dup).Error
require.Error(t, err)
assert.True(t,
strings.Contains(strings.ToLower(err.Error()), "unique") ||
strings.Contains(strings.ToLower(err.Error()), "duplicate"),
"expected unique-constraint error, got %v", err)
}
func TestNormalizeStatusPageDomain(t *testing.T) {
got, err := models.NormalizeStatusPageDomain(" Status.Example.COM. ")
require.NoError(t, err)
assert.Equal(t, "status.example.com", got)
for _, input := range []string{"https://example.com", "example.com:443", "127.0.0.1", "*.example.com", "-bad.example.com"} {
_, err := models.NormalizeStatusPageDomain(input)
assert.Error(t, err, input)
}
}