feat(store): schedules, notification_channels, domain last_check_status + методы

This commit is contained in:
2026-07-04 13:10:42 +07:00
parent 1cdb32b747
commit 6fd847a909
10 changed files with 814 additions and 5 deletions
+267
View File
@@ -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)
}
}