From e18d271970377c3c2af8978dd2db6e129c249a7c Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 8 May 2026 23:45:10 +0700 Subject: [PATCH] test: add unit tests for config, fire client, scheduler --- self-hosted/config_test.go | 215 ++++++++++++++++++++++++++++++++ self-hosted/fire_client_test.go | 174 ++++++++++++++++++++++++++ self-hosted/scheduler_test.go | 104 +++++++++++++++ 3 files changed, 493 insertions(+) create mode 100644 self-hosted/config_test.go create mode 100644 self-hosted/fire_client_test.go create mode 100644 self-hosted/scheduler_test.go diff --git a/self-hosted/config_test.go b/self-hosted/config_test.go new file mode 100644 index 0000000..dc5364a --- /dev/null +++ b/self-hosted/config_test.go @@ -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 +} diff --git a/self-hosted/fire_client_test.go b/self-hosted/fire_client_test.go new file mode 100644 index 0000000..0835a57 --- /dev/null +++ b/self-hosted/fire_client_test.go @@ -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) + } +} diff --git a/self-hosted/scheduler_test.go b/self-hosted/scheduler_test.go new file mode 100644 index 0000000..998ae32 --- /dev/null +++ b/self-hosted/scheduler_test.go @@ -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) + } + } +}