feat(store): schedules, notification_channels, domain last_check_status + методы
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// TestUpsertSchedule_InsertThenUpdate verifies UpsertSchedule inserts a new
|
||||
// row for a project on the first call and updates that same row (rather
|
||||
// than inserting a second one) on a subsequent call, per the
|
||||
// ON CONFLICT (project_id) DO UPDATE clause.
|
||||
func TestUpsertSchedule_InsertThenUpdate(t *testing.T) {
|
||||
s, ctx := newStore(t)
|
||||
_, p, err := s.RegisterUser(ctx, "sched-upsert@example.com", "argon2-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
created, err := s.UpsertSchedule(ctx, p.ID, 1800, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.IntervalSeconds != 1800 || !created.Enabled {
|
||||
t.Fatalf("unexpected created schedule: %+v", created)
|
||||
}
|
||||
|
||||
updated, err := s.UpsertSchedule(ctx, p.ID, 7200, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.ID != created.ID {
|
||||
t.Fatalf("expected same row id, got created=%s updated=%s", created.ID, updated.ID)
|
||||
}
|
||||
if updated.IntervalSeconds != 7200 || updated.Enabled {
|
||||
t.Fatalf("unexpected updated schedule: %+v", updated)
|
||||
}
|
||||
|
||||
got, err := s.GetSchedule(ctx, p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.IntervalSeconds != 7200 || got.Enabled {
|
||||
t.Fatalf("GetSchedule mismatch after update: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSchedule_NoRowReturnsErrNoRows verifies the contract used by the API
|
||||
// layer (Task 5): a project with no schedule row yet returns pgx.ErrNoRows,
|
||||
// which the API translates into the default {interval:3600, enabled:false}.
|
||||
func TestGetSchedule_NoRowReturnsErrNoRows(t *testing.T) {
|
||||
s, ctx := newStore(t)
|
||||
_, p, err := s.RegisterUser(ctx, "sched-norow@example.com", "argon2-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := s.GetSchedule(ctx, p.ID); !errors.Is(err, pgx.ErrNoRows) {
|
||||
t.Fatalf("expected pgx.ErrNoRows, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListDueSchedules verifies the due-selection logic: an enabled schedule
|
||||
// that never ran (last_run_at IS NULL) is due; a disabled schedule is never
|
||||
// due; and an enabled schedule that ran recently with a long interval is not
|
||||
// yet due.
|
||||
func TestListDueSchedules(t *testing.T) {
|
||||
s, ctx := newStore(t)
|
||||
now := time.Now().UTC()
|
||||
|
||||
_, neverRunProject, err := s.RegisterUser(ctx, "sched-neverrun@example.com", "argon2-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.UpsertSchedule(ctx, neverRunProject.ID, 3600, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, disabledProject, err := s.RegisterUser(ctx, "sched-disabled@example.com", "argon2-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.UpsertSchedule(ctx, disabledProject.ID, 60, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, recentProject, err := s.RegisterUser(ctx, "sched-recent@example.com", "argon2-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.UpsertSchedule(ctx, recentProject.ID, 3600, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.TouchScheduleRun(ctx, recentProject.ID, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
due, err := s.ListDueSchedules(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
byProject := make(map[uuid.UUID]bool, len(due))
|
||||
for _, d := range due {
|
||||
byProject[d.ProjectID] = true
|
||||
}
|
||||
|
||||
if !byProject[neverRunProject.ID] {
|
||||
t.Errorf("expected enabled/never-run schedule for project %s to be due", neverRunProject.ID)
|
||||
}
|
||||
if byProject[disabledProject.ID] {
|
||||
t.Errorf("did not expect disabled schedule for project %s to be due", disabledProject.ID)
|
||||
}
|
||||
if byProject[recentProject.ID] {
|
||||
t.Errorf("did not expect recently-run schedule (long interval) for project %s to be due", recentProject.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTouchScheduleRun_SetsLastRunAt verifies TouchScheduleRun persists
|
||||
// last_run_at, which GetSchedule then returns as a non-nil *time.Time close
|
||||
// to the value passed in.
|
||||
func TestTouchScheduleRun_SetsLastRunAt(t *testing.T) {
|
||||
s, ctx := newStore(t)
|
||||
_, p, err := s.RegisterUser(ctx, "sched-touch@example.com", "argon2-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.UpsertSchedule(ctx, p.ID, 3600, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
at := time.Now().UTC().Truncate(time.Second)
|
||||
if err := s.TouchScheduleRun(ctx, p.ID, at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := s.GetSchedule(ctx, p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.LastRunAt == nil {
|
||||
t.Fatal("expected non-nil LastRunAt after TouchScheduleRun")
|
||||
}
|
||||
if diff := got.LastRunAt.Sub(at); diff < -time.Second || diff > time.Second {
|
||||
t.Fatalf("expected LastRunAt ~%v, got %v", at, *got.LastRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelCRUD_ScopedByProject verifies CreateChannel/ListChannels/
|
||||
// GetChannel/DeleteChannel round-trip correctly and that GetChannel scopes
|
||||
// by project_id: looking up a channel with the wrong project ID must fail
|
||||
// with pgx.ErrNoRows rather than returning another tenant's channel.
|
||||
func TestChannelCRUD_ScopedByProject(t *testing.T) {
|
||||
s, ctx := newStore(t)
|
||||
_, p1, err := s.RegisterUser(ctx, "chan-owner@example.com", "argon2-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, p2, err := s.RegisterUser(ctx, "chan-other@example.com", "argon2-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := json.RawMessage(`{"webhook_url":"https://example.com/hook"}`)
|
||||
ch, err := s.CreateChannel(ctx, p1.ID, "telegram", cfg, "enc-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// jsonb round-trips through Postgres with its own canonical formatting
|
||||
// (e.g. a space after ':'), so compare decoded values rather than raw
|
||||
// bytes.
|
||||
var gotCfg, wantCfg map[string]string
|
||||
if err := json.Unmarshal(ch.Config, &gotCfg); err != nil {
|
||||
t.Fatalf("unmarshal returned config: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(cfg, &wantCfg); err != nil {
|
||||
t.Fatalf("unmarshal expected config: %v", err)
|
||||
}
|
||||
if ch.Type != "telegram" || !ch.Enabled || gotCfg["webhook_url"] != wantCfg["webhook_url"] || ch.SecretEnc != "enc-secret" {
|
||||
t.Fatalf("unexpected created channel: %+v", ch)
|
||||
}
|
||||
|
||||
list, err := s.ListChannels(ctx, p1.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != ch.ID {
|
||||
t.Fatalf("unexpected ListChannels result: %+v", list)
|
||||
}
|
||||
|
||||
enabledList, err := s.ListEnabledChannels(ctx, p1.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(enabledList) != 1 || enabledList[0].ID != ch.ID {
|
||||
t.Fatalf("unexpected ListEnabledChannels result: %+v", enabledList)
|
||||
}
|
||||
|
||||
got, err := s.GetChannel(ctx, ch.ID, p1.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != ch.ID {
|
||||
t.Fatalf("GetChannel mismatch: %+v", got)
|
||||
}
|
||||
|
||||
if _, err := s.GetChannel(ctx, ch.ID, p2.ID); !errors.Is(err, pgx.ErrNoRows) {
|
||||
t.Fatalf("expected pgx.ErrNoRows for foreign project, got %v", err)
|
||||
}
|
||||
|
||||
if err := s.DeleteChannel(ctx, ch.ID, p1.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.GetChannel(ctx, ch.ID, p1.ID); !errors.Is(err, pgx.ErrNoRows) {
|
||||
t.Fatalf("expected pgx.ErrNoRows after delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDomainStatus_RoundTrip verifies SetDomainStatus/GetDomainStatus
|
||||
// round-trip, and that a freshly-imported domain defaults to "unknown" per
|
||||
// the migration's DEFAULT 'unknown'.
|
||||
func TestDomainStatus_RoundTrip(t *testing.T) {
|
||||
s, ctx := newStore(t)
|
||||
_, p, err := s.RegisterUser(ctx, "domain-status@example.com", "argon2-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
acc, err := s.CreateAccount(ctx, p.ID, "selectel", "enc-blob", "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := s.CreateDomain(ctx, p.ID, acc.ID, "example.com", "zone-1", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := s.GetDomainStatus(ctx, d.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "unknown" {
|
||||
t.Fatalf("expected default status 'unknown', got %q", status)
|
||||
}
|
||||
|
||||
if err := s.SetDomainStatus(ctx, d.ID, "ok"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err = s.GetDomainStatus(ctx, d.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "ok" {
|
||||
t.Fatalf("expected status 'ok' after SetDomainStatus, got %q", status)
|
||||
}
|
||||
|
||||
domains, err := s.ListDomains(ctx, p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(domains) != 1 || domains[0].LastCheckStatus != "ok" {
|
||||
t.Fatalf("expected ListDomains to reflect updated status: %+v", domains)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user