Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
402 строки
15 KiB
Go
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)
|
|
}
|
|
}
|