Files
volumen/internal/scheduler/scheduler_test.go
T
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

152 lines
4.3 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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")
}
})
}