test: add unit tests for config, fire client, scheduler

This commit is contained in:
tiennm99 committed 2026-05-08 23:45:10 +07:00
1 parent 9c4d7a532f
commit e18d271970
3 files changed
+493

No files matched your search

+215
View File
@@ -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
}
+174
View File
@@ -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)
}
}
+104
View File
@@ -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)
}
}
}