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
Assisted-by: GLM 5.3
152 lines
4.3 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|