// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package scheduler import ( "os" "path/filepath" "testing" "testing/synctest" "time" "sourcedock.dev/petrbalvin/volumen/internal/post" "sourcedock.dev/petrbalvin/volumen/internal/store" ) func newStoreWith(t *testing.T, files map[string]string) *store.Store { t.Helper() dir := filepath.Join(t.TempDir(), "posts") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } for name, body := range files { if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { t.Fatalf("write: %v", err) } } return store.New(store.Options{ContentDir: dir, DefaultLang: "en", RevisionLimit: 10}) } func TestPublishDue(t *testing.T) { due := time.Now().UTC().Format("2006-01-02") st := newStoreWith(t, map[string]string{ "due.md": "+++\nslug = \"due\"\npublish_at = " + due + "\n+++\nx\n", "later.md": "+++\nslug = \"later\"\npublish_at = 2999-01-01\n+++\nx\n", "stamped.md": "+++\nslug = \"stamped\"\npublish_at = " + due + "\ndate = 2020-01-01\n+++\nx\n", }) published, _ := PublishDueWith(st, nil) if len(published) != 2 { t.Fatalf("published = %v", published) } if p := st.Find("due", ""); p == nil || p.Scheduled() { t.Fatal("due post still scheduled") } if got := st.Find("due", "").DateString(); got != due { t.Fatalf("date defaulted to %q, want %q", got, due) } if got := st.Find("stamped", "").DateString(); got != "2020-01-01" { t.Fatalf("existing date overwritten: %q", got) } if p := st.Find("later", ""); p == nil || !p.Scheduled() { t.Fatal("future post was published") } } func TestDuePosts(t *testing.T) { due := time.Now().UTC().Format("2006-01-02") st := newStoreWith(t, map[string]string{ "due.md": "+++\nslug = \"due\"\npublish_at = " + due + "\n+++\nx\n", "later.md": "+++\nslug = \"later\"\npublish_at = 2999-01-01\n+++\nx\n", }) if got := len(DuePosts(st)); got != 1 { t.Fatalf("DuePosts = %d, want 1", got) } } func TestRunStops(t *testing.T) { synctest.Test(t, func(t *testing.T) { st := newStoreWith(t, nil) stop := make(chan struct{}) done := make(chan struct{}) go func() { // Ten intervals, so the loop has really started ticking by the // time it is stopped. Run(st, 10*time.Millisecond, stop, nil) close(done) }() time.Sleep(100 * time.Millisecond) close(stop) synctest.Wait() select { case <-done: default: t.Fatal("Run did not stop") } }) } // The in-app loop must publish on its own, without the CLI: the ticker // branch is the whole point of the [scheduler] section. // The loop publishes on its own, and synctest runs its interval and the // wait in virtual time: the test does not spend a second of the wall // clock on a one-second ticker. func TestRunPublishesDuePosts(t *testing.T) { synctest.Test(t, func(t *testing.T) { st := newStoreWith(t, map[string]string{ "now.md": "+++\nslug = \"now\"\npublish_at = 2000-01-01\n+++\nbody\n", "later.md": "+++\nslug = \"later\"\npublish_at = 2999-01-01\n+++\nbody\n", }) stop := make(chan struct{}) done := make(chan struct{}) published := make(chan string, 4) go func() { defer close(done) Run(st, MinInterval, stop, func(p *post.Post) { published <- p.Slug() }) }() // The start-up sweep runs before the first tick. synctest.Wait() if len(DuePosts(st)) != 0 { t.Fatal("the start-up sweep did not publish the due post") } select { case slug := <-published: if slug != "now" { t.Fatalf("published %q, want the due post", slug) } default: t.Fatal("the sink was not called") } // Then the loop keeps running on its interval. time.Sleep(MinInterval * 3) synctest.Wait() close(stop) <-done if len(DuePosts(st)) != 0 { t.Fatal("the loop did not keep the due post published") } }) } // An interval below the minimum is refused rather than handed to // time.NewTicker, which panics on a non-positive duration. func TestRunRefusesAnUnusableInterval(t *testing.T) { synctest.Test(t, func(t *testing.T) { st := newStoreWith(t, nil) done := make(chan struct{}) go func() { defer close(done) Run(st, 0, make(chan struct{}), nil) }() synctest.Wait() select { case <-done: default: t.Fatal("Run did not return for a zero interval") } }) }