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) } }