mirror of
https://github.com/tiennm99/claude-code-routine-trigger.git
synced 2026-10-03 22:13:05 +00:00
test: add unit tests for config, fire client, scheduler
This commit is contained in:
1 parent
9c4d7a532f
commit
e18d271970
3 files changed
+493
No files matched your search
@@ -0,0 +1,215 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func clearEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, k := range []string{"ROUTINE_FIRE_URL", "ROUTINE_FIRE_TOKEN", "CRON_SCHEDULE", "TZ", "TEXT_TEMPLATE", "LOG_LEVEL"} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
}
|
||||
|
||||
func setBaseEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("ROUTINE_FIRE_URL", "https://example.test/fire")
|
||||
t.Setenv("ROUTINE_FIRE_TOKEN", "tok")
|
||||
t.Setenv("CRON_SCHEDULE", "0 9 * * *")
|
||||
}
|
||||
|
||||
func TestLoad_Happy(t *testing.T) {
|
||||
clearEnv(t)
|
||||
setBaseEnv(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(cfg.Schedules) != 1 || cfg.Schedules[0] != "0 9 * * *" {
|
||||
t.Fatalf("schedules = %v", cfg.Schedules)
|
||||
}
|
||||
if cfg.Location != time.UTC {
|
||||
t.Fatalf("default tz should be UTC, got %v", cfg.Location)
|
||||
}
|
||||
if cfg.LogLevel != "info" {
|
||||
t.Fatalf("default log level should be info, got %q", cfg.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MultiCronSemicolon(t *testing.T) {
|
||||
clearEnv(t)
|
||||
setBaseEnv(t)
|
||||
t.Setenv("CRON_SCHEDULE", "0 17 * * *;0 22 * * *")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(cfg.Schedules) != 2 {
|
||||
t.Fatalf("want 2, got %d: %v", len(cfg.Schedules), cfg.Schedules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MultiCronNewline(t *testing.T) {
|
||||
clearEnv(t)
|
||||
setBaseEnv(t)
|
||||
t.Setenv("CRON_SCHEDULE", "0 17 * * *\n0 22 * * *")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(cfg.Schedules) != 2 {
|
||||
t.Fatalf("want 2, got %d: %v", len(cfg.Schedules), cfg.Schedules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MixedSeparatorsAndWhitespace(t *testing.T) {
|
||||
clearEnv(t)
|
||||
setBaseEnv(t)
|
||||
t.Setenv("CRON_SCHEDULE", " 0 17 * * * ;\n 0 22 * * * ;;\n")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(cfg.Schedules) != 2 {
|
||||
t.Fatalf("want 2 trimmed schedules, got %v", cfg.Schedules)
|
||||
}
|
||||
if cfg.Schedules[0] != "0 17 * * *" || cfg.Schedules[1] != "0 22 * * *" {
|
||||
t.Fatalf("unexpected trimmed values: %v", cfg.Schedules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingFireURL(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("ROUTINE_FIRE_TOKEN", "tok")
|
||||
t.Setenv("CRON_SCHEDULE", "0 9 * * *")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil || !strings.Contains(err.Error(), "ROUTINE_FIRE_URL") {
|
||||
t.Fatalf("want missing url error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingToken(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("ROUTINE_FIRE_URL", "https://example.test/fire")
|
||||
t.Setenv("CRON_SCHEDULE", "0 9 * * *")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil || !strings.Contains(err.Error(), "ROUTINE_FIRE_TOKEN") {
|
||||
t.Fatalf("want missing token error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_EmptyCron(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("ROUTINE_FIRE_URL", "https://example.test/fire")
|
||||
t.Setenv("ROUTINE_FIRE_TOKEN", "tok")
|
||||
t.Setenv("CRON_SCHEDULE", "")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil || !strings.Contains(err.Error(), "CRON_SCHEDULE") {
|
||||
t.Fatalf("want missing cron error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidCron(t *testing.T) {
|
||||
clearEnv(t)
|
||||
setBaseEnv(t)
|
||||
t.Setenv("CRON_SCHEDULE", "not-a-cron")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid cron") {
|
||||
t.Fatalf("want invalid cron error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidTZ(t *testing.T) {
|
||||
clearEnv(t)
|
||||
setBaseEnv(t)
|
||||
t.Setenv("TZ", "Atlantis/Lost")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid TZ") {
|
||||
t.Fatalf("want invalid TZ error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_DefaultTemplate(t *testing.T) {
|
||||
clearEnv(t)
|
||||
setBaseEnv(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out, err := renderTemplate(cfg, time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC), "0 9 * * *")
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(out, "Scheduled trigger at ") {
|
||||
t.Fatalf("default template output unexpected: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_CustomTemplate(t *testing.T) {
|
||||
clearEnv(t)
|
||||
setBaseEnv(t)
|
||||
t.Setenv("TEXT_TEMPLATE", "hi {{.Cron}}")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out, err := renderTemplate(cfg, time.Now(), "0 9 * * *")
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if out != "hi 0 9 * * *" {
|
||||
t.Fatalf("custom template output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidTemplate(t *testing.T) {
|
||||
clearEnv(t)
|
||||
setBaseEnv(t)
|
||||
t.Setenv("TEXT_TEMPLATE", "{{.broken")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid TEXT_TEMPLATE") {
|
||||
t.Fatalf("want invalid template error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_LogLevelDefault(t *testing.T) {
|
||||
clearEnv(t)
|
||||
setBaseEnv(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.LogLevel != "info" {
|
||||
t.Fatalf("got %q", cfg.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
// renderTemplate runs cfg.Template with the documented vars; mirror of
|
||||
// FireClient.renderText for test isolation.
|
||||
func renderTemplate(cfg *Config, now time.Time, expr string) (string, error) {
|
||||
data := map[string]any{
|
||||
"Now": now,
|
||||
"LocalTime": now.In(cfg.Location).Format("2006-01-02 15:04 MST"),
|
||||
"Cron": expr,
|
||||
}
|
||||
var sb strings.Builder
|
||||
if err := cfg.Template.Execute(&sb, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
func captureLogs() (*slog.Logger, *bytes.Buffer) {
|
||||
var buf bytes.Buffer
|
||||
h := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
return slog.New(h), &buf
|
||||
}
|
||||
|
||||
func mustTemplate(t *testing.T, raw string) *template.Template {
|
||||
t.Helper()
|
||||
tpl, err := template.New("t").Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("template parse: %v", err)
|
||||
}
|
||||
return tpl
|
||||
}
|
||||
|
||||
func TestFire_HappyPath(t *testing.T) {
|
||||
var gotMethod, gotAuth, gotVersion, gotBeta, gotContentType string
|
||||
var gotBody []byte
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotVersion = r.Header.Get("anthropic-version")
|
||||
gotBeta = r.Header.Get("anthropic-beta")
|
||||
gotContentType = r.Header.Get("Content-Type")
|
||||
gotBody, _ = io.ReadAll(r.Body)
|
||||
_, _ = w.Write([]byte(`{"type":"routine_fire","claude_code_session_id":"sess_1","claude_code_session_url":"https://claude.ai/code/sess_1"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
logger, logbuf := captureLogs()
|
||||
c := &FireClient{
|
||||
URL: srv.URL,
|
||||
Token: "tok",
|
||||
HTTP: &http.Client{Timeout: 5 * time.Second},
|
||||
Template: mustTemplate(t, "fire {{.Cron}}"),
|
||||
TZ: time.UTC,
|
||||
Log: logger,
|
||||
}
|
||||
|
||||
if err := c.Fire(context.Background(), "0 9 * * *"); err != nil {
|
||||
t.Fatalf("Fire returned error: %v", err)
|
||||
}
|
||||
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Errorf("method = %q", gotMethod)
|
||||
}
|
||||
if gotAuth != "Bearer tok" {
|
||||
t.Errorf("auth = %q", gotAuth)
|
||||
}
|
||||
if gotVersion != headerVersion {
|
||||
t.Errorf("anthropic-version = %q", gotVersion)
|
||||
}
|
||||
if gotBeta != headerBeta {
|
||||
t.Errorf("anthropic-beta = %q", gotBeta)
|
||||
}
|
||||
if gotContentType != "application/json" {
|
||||
t.Errorf("content-type = %q", gotContentType)
|
||||
}
|
||||
|
||||
var body fireRequest
|
||||
if err := json.Unmarshal(gotBody, &body); err != nil {
|
||||
t.Fatalf("body unmarshal: %v", err)
|
||||
}
|
||||
if body.Text != "fire 0 9 * * *" {
|
||||
t.Errorf("body.text = %q", body.Text)
|
||||
}
|
||||
|
||||
if !strings.Contains(logbuf.String(), "fire ok") {
|
||||
t.Errorf("expected 'fire ok' log, got: %s", logbuf.String())
|
||||
}
|
||||
if !strings.Contains(logbuf.String(), "https://claude.ai/code/sess_1") {
|
||||
t.Errorf("expected session url in log, got: %s", logbuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFire_NonOK(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
logger, logbuf := captureLogs()
|
||||
c := &FireClient{
|
||||
URL: srv.URL,
|
||||
Token: "tok",
|
||||
HTTP: &http.Client{Timeout: 5 * time.Second},
|
||||
Template: mustTemplate(t, "x"),
|
||||
TZ: time.UTC,
|
||||
Log: logger,
|
||||
}
|
||||
|
||||
if err := c.Fire(context.Background(), "0 9 * * *"); err != nil {
|
||||
t.Fatalf("Fire should swallow non-2xx, got error: %v", err)
|
||||
}
|
||||
out := logbuf.String()
|
||||
if !strings.Contains(out, "non-2xx") {
|
||||
t.Errorf("expected non-2xx log, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "401") {
|
||||
t.Errorf("expected status 401 in log, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFire_NetworkError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
srv.Close() // close immediately so requests fail
|
||||
|
||||
logger, logbuf := captureLogs()
|
||||
c := &FireClient{
|
||||
URL: srv.URL,
|
||||
Token: "tok",
|
||||
HTTP: &http.Client{Timeout: 1 * time.Second},
|
||||
Template: mustTemplate(t, "x"),
|
||||
TZ: time.UTC,
|
||||
Log: logger,
|
||||
}
|
||||
|
||||
if err := c.Fire(context.Background(), "0 9 * * *"); err != nil {
|
||||
t.Fatalf("Fire should swallow network error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(logbuf.String(), "fire request failed") {
|
||||
t.Errorf("expected request-failed log, got: %s", logbuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFire_TemplateUsesNowFunc(t *testing.T) {
|
||||
var gotBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotBody, _ = io.ReadAll(r.Body)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
fixed := time.Date(2026, 5, 8, 23, 30, 0, 0, time.UTC)
|
||||
logger, _ := captureLogs()
|
||||
c := &FireClient{
|
||||
URL: srv.URL,
|
||||
Token: "tok",
|
||||
HTTP: &http.Client{Timeout: 5 * time.Second},
|
||||
Template: mustTemplate(t, "{{.LocalTime}}"),
|
||||
TZ: time.UTC,
|
||||
Log: logger,
|
||||
NowFunc: func() time.Time { return fixed },
|
||||
}
|
||||
|
||||
if err := c.Fire(context.Background(), "0 9 * * *"); err != nil {
|
||||
t.Fatalf("Fire: %v", err)
|
||||
}
|
||||
var body fireRequest
|
||||
if err := json.Unmarshal(gotBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if body.Text != "2026-05-08 23:30 UTC" {
|
||||
t.Errorf("body.text = %q", body.Text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestScheduler_RegistersAllEntries(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tpl, _ := template.New("t").Parse("x")
|
||||
cfg := &Config{
|
||||
FireURL: srv.URL,
|
||||
Token: "tok",
|
||||
Schedules: []string{"0 17 * * *", "0 22 * * *", "*/5 * * * *"},
|
||||
Location: time.UTC,
|
||||
Template: tpl,
|
||||
LogLevel: "info",
|
||||
}
|
||||
logger, _ := captureLogs()
|
||||
fire := &FireClient{
|
||||
URL: srv.URL,
|
||||
Token: "tok",
|
||||
HTTP: &http.Client{Timeout: 1 * time.Second},
|
||||
Template: tpl,
|
||||
TZ: time.UTC,
|
||||
Log: logger,
|
||||
}
|
||||
|
||||
s, err := NewScheduler(cfg, fire, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScheduler: %v", err)
|
||||
}
|
||||
if got := s.EntryCount(); got != 3 {
|
||||
t.Fatalf("EntryCount = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduler_StartStop(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tpl, _ := template.New("t").Parse("x")
|
||||
cfg := &Config{
|
||||
FireURL: srv.URL,
|
||||
Token: "tok",
|
||||
Schedules: []string{"0 17 * * *"},
|
||||
Location: time.UTC,
|
||||
Template: tpl,
|
||||
LogLevel: "info",
|
||||
}
|
||||
logger, _ := captureLogs()
|
||||
fire := &FireClient{
|
||||
URL: srv.URL,
|
||||
Token: "tok",
|
||||
HTTP: &http.Client{Timeout: 1 * time.Second},
|
||||
Template: tpl,
|
||||
TZ: time.UTC,
|
||||
Log: logger,
|
||||
}
|
||||
|
||||
s, err := NewScheduler(cfg, fire, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScheduler: %v", err)
|
||||
}
|
||||
s.Start()
|
||||
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.Stop(stopCtx)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Stop did not return within 3s")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLogger_UnknownLevelFallsBackToInfo(t *testing.T) {
|
||||
l := newLogger("zoinks")
|
||||
if l == nil {
|
||||
t.Fatal("logger is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLogger_KnownLevels(t *testing.T) {
|
||||
for _, lvl := range []string{"", "debug", "info", "warn", "warning", "error", "ERROR", " Debug "} {
|
||||
if l := newLogger(lvl); l == nil {
|
||||
t.Fatalf("nil logger for %q", lvl)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user