268 lines
7.8 KiB
Go
268 lines
7.8 KiB
Go
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)
|
|
}
|
|
}
|