mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Tranche 6 — 15 of 17 raw sites in cron.go. Adds cron_jobs and cron_executions to schema.sql and a full query set: job CRUD, owner joins (via project and via service), execution list/insert/finish, due-job scan for the scheduler, run bookkeeping, and retention trim. The filtered list query and the sparse UPDATE builder stay raw — both are genuinely dynamic. Go models keep string IDs; cronJobFrom and cronExecutionFrom map uuid/null types at the boundary. TestCronLiveInvariants covers insert/read, both owner joins, the trigger read, execution lifecycle, retention trim, and delete on live Postgres. Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
194 lines
5.9 KiB
Go
194 lines
5.9 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"containr/internal/database"
|
|
"containr/internal/database/sqlcdb"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func TestCalculateNextRun(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
schedule string
|
|
timezone string
|
|
wantErr bool
|
|
}{
|
|
{"every minute", "* * * * *", "", false},
|
|
{"daily at 4am", "0 4 * * *", "UTC", false},
|
|
{"weekday run", "0 9 * * 1-5", "Europe/Prague", false},
|
|
{"whitespace tolerated", " */15 * * * * ", "", false},
|
|
{"empty schedule", "", "", true},
|
|
{"bad field count", "* * *", "", true},
|
|
{"garbage", "not a cron", "", true},
|
|
{"bad timezone", "0 4 * * *", "Mars/Olympus", true},
|
|
}
|
|
now := time.Now()
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
next, err := calculateNextRun(tc.schedule, tc.timezone)
|
|
if tc.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("expected error, got next=%v", next)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if next == nil {
|
|
t.Fatal("expected non-nil next run")
|
|
}
|
|
if !next.After(now) {
|
|
t.Errorf("next run %v is not after %v", next, now)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCalculateNextRunTimezone(t *testing.T) {
|
|
// CRON_TZ must change the computed instant: 04:00 in a fixed-offset zone
|
|
// lands at a different UTC hour than 04:00 UTC.
|
|
utcNext, err := calculateNextRun("0 4 * * *", "UTC")
|
|
if err != nil {
|
|
t.Fatalf("utc: %v", err)
|
|
}
|
|
tokyoNext, err := calculateNextRun("0 4 * * *", "Asia/Tokyo")
|
|
if err != nil {
|
|
t.Fatalf("tokyo: %v", err)
|
|
}
|
|
if utcNext.Equal(*tokyoNext) {
|
|
t.Fatalf("timezone had no effect: both %v", utcNext)
|
|
}
|
|
if !strings.EqualFold(utcNext.Location().String(), "Local") {
|
|
// UTC-scheduled runs still land in server-local time representation.
|
|
t.Logf("utc next: %v / tokyo next: %v", utcNext, tokyoNext)
|
|
}
|
|
}
|
|
|
|
// TestCronLiveInvariants exercises the cron.sql queries on live Postgres:
|
|
// insert/read/owner join, execution lifecycle, run bookkeeping, retention trim.
|
|
func TestCronLiveInvariants(t *testing.T) {
|
|
dsn := testDSNString(t)
|
|
db, err := database.NewConnection(dsn)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer db.Close()
|
|
ctx := context.Background()
|
|
q := sqlcdb.New(db.DB)
|
|
|
|
userID := uuid.New()
|
|
projectID := uuid.New()
|
|
envID := uuid.New()
|
|
serviceID := uuid.New()
|
|
jobID := uuid.New()
|
|
suffix := strings.ReplaceAll(jobID.String()[:8], "-", "")
|
|
must := func(err error) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
}
|
|
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
|
|
userID, "cron-"+suffix+"@test.local")
|
|
must(err)
|
|
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
|
|
projectID, "cron-"+suffix, userID)
|
|
must(err)
|
|
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
|
|
envID, projectID)
|
|
must(err)
|
|
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type)
|
|
VALUES ($1, $2, $3, $4, 'web', 'image')`,
|
|
serviceID, "cron-"+suffix, projectID, envID)
|
|
must(err)
|
|
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
|
|
|
|
next, err := calculateNextRun("0 4 * * *", "UTC")
|
|
if err != nil {
|
|
t.Fatalf("schedule: %v", err)
|
|
}
|
|
must(q.InsertCronJob(ctx, sqlcdb.InsertCronJobParams{
|
|
ID: jobID, ProjectID: projectID, ServiceID: serviceID,
|
|
Name: "job-" + suffix, Schedule: "0 4 * * *", Command: "echo hi",
|
|
Timezone: sql.NullString{String: "UTC", Valid: true},
|
|
Enabled: sql.NullBool{Bool: true, Valid: true},
|
|
NextRunAt: ntPtr(next),
|
|
}))
|
|
job, err := q.GetCronJob(ctx, jobID)
|
|
if err != nil || job.Name != "job-"+suffix || job.Command != "echo hi" {
|
|
t.Fatalf("job: %v %+v", err, job)
|
|
}
|
|
owner, err := q.GetCronJobOwner(ctx, jobID)
|
|
if err != nil || owner != userID {
|
|
t.Fatalf("owner: %v %v", err, owner)
|
|
}
|
|
owner2, err := q.GetCronOwnerViaService(ctx, serviceID)
|
|
if err != nil || owner2 != userID {
|
|
t.Fatalf("owner via service: %v %v", err, owner2)
|
|
}
|
|
pid, err := q.GetCronJobProjectID(ctx, jobID)
|
|
if err != nil || pid != projectID {
|
|
t.Fatalf("project: %v %v", err, pid)
|
|
}
|
|
|
|
// Trigger-shaped read.
|
|
trig, err := q.GetCronJobForTrigger(ctx, jobID)
|
|
if err != nil || trig.ServiceID != serviceID || trig.OwnerID != userID {
|
|
t.Fatalf("trigger: %v %+v", err, trig)
|
|
}
|
|
|
|
// Execution lifecycle + retention trim.
|
|
for i := 0; i < 3; i++ {
|
|
execID := uuid.New()
|
|
must(q.InsertCronExecution(ctx, sqlcdb.InsertCronExecutionParams{
|
|
ID: execID, CronJobID: jobID,
|
|
StartedAt: time.Now().Add(time.Duration(i) * time.Second),
|
|
Status: sql.NullString{String: "running", Valid: true},
|
|
}))
|
|
must(q.FinishCronExecution(ctx, sqlcdb.FinishCronExecutionParams{
|
|
FinishedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
|
Status: sql.NullString{String: "success", Valid: true},
|
|
Output: sql.NullString{String: "ok", Valid: true},
|
|
Error: sql.NullString{String: "", Valid: true},
|
|
ID: execID,
|
|
}))
|
|
}
|
|
execs, err := q.ListCronExecutions(ctx, jobID)
|
|
if err != nil || len(execs) != 3 {
|
|
t.Fatalf("execs: %v %d", err, len(execs))
|
|
}
|
|
if execs[0].Status.String != "success" || !execs[0].FinishedAt.Valid {
|
|
t.Fatalf("exec row: %+v", execs[0])
|
|
}
|
|
must(q.TrimCronExecutions(ctx, sqlcdb.TrimCronExecutionsParams{CronJobID: jobID, Limit: 1}))
|
|
execs, _ = q.ListCronExecutions(ctx, jobID)
|
|
if len(execs) != 1 {
|
|
t.Fatalf("trim kept %d, want 1", len(execs))
|
|
}
|
|
|
|
must(q.UpdateCronJobRun(ctx, sqlcdb.UpdateCronJobRunParams{
|
|
LastRunAt: sql.NullTime{Time: time.Now(), Valid: true},
|
|
LastStatus: sql.NullString{String: "success", Valid: true},
|
|
LastOutput: sql.NullString{String: "ok", Valid: true},
|
|
NextRunAt: ntPtr(next),
|
|
ID: jobID,
|
|
}))
|
|
job, _ = q.GetCronJob(ctx, jobID)
|
|
if job.LastStatus.String != "success" || !job.LastRunAt.Valid {
|
|
t.Fatalf("post-run job: %+v", job)
|
|
}
|
|
|
|
must(q.DeleteCronJob(ctx, jobID))
|
|
if _, err := q.GetCronJob(ctx, jobID); err == nil {
|
|
t.Fatal("deleted job still readable")
|
|
}
|
|
}
|