Files
cdnutterandClaude Opus 5.5 f3185ba7bd Add scheduled and manual backups
Backups are .tar.gz archives of a consistent database snapshot (VACUUM
INTO) plus the images folder. They run on a cron schedule, daily at 3:00
by default, and the newest N scheduled ones are kept (7 by default).
Manual backups are never pruned.

Settings → Backups offers schedule presets or a custom cron expression,
retention, "Back up now", and download/delete for each backup. A small
built-in parser handles standard 5-field cron syntax with names, ranges,
steps and @daily-style shorthands.

Backups go to CANON_BACKUP_DIR (default data/backups). Compose mounts a
separate canon-backups volume at /backups, overridable with
CANON_BACKUP_PATH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:30:40 -07:00

51 lines
1.6 KiB
Go

package backup
import (
"testing"
"time"
)
func TestNext(t *testing.T) {
utc := func(s string) time.Time {
v, err := time.Parse("2006-01-02 15:04", s)
if err != nil {
t.Fatal(err)
}
return v
}
cases := []struct{ expr, from, want string }{
{"0 3 * * *", "2026-09-30 02:59", "2026-09-30 03:00"},
{"0 3 * * *", "2026-09-30 03:00", "2026-10-01 03:00"},
{"@daily", "2026-12-31 23:30", "2027-01-01 00:00"},
{"*/15 * * * *", "2026-09-30 10:07", "2026-09-30 10:15"},
{"30 4 * * sun", "2026-09-30 00:00", "2026-10-04 04:30"}, // Wed → Sun
{"30 4 * * 7", "2026-09-30 00:00", "2026-10-04 04:30"},
{"0 0 1,15 * *", "2026-09-02 00:00", "2026-09-15 00:00"},
{"0 9 * * mon-fri", "2026-10-02 10:00", "2026-10-05 09:00"}, // Fri after 9 → Mon
{"0 0 29 2 *", "2026-03-01 00:00", "2028-02-29 00:00"},
{"0 12 13 * 5", "2026-09-30 00:00", "2026-10-02 12:00"}, // Friday OR the 13th
{"5/20 * * * *", "2026-09-30 10:26", "2026-09-30 10:45"},
}
for _, c := range cases {
s, err := ParseCron(c.expr)
if err != nil {
t.Fatalf("%s: %v", c.expr, err)
}
if got := s.Next(utc(c.from)); !got.Equal(utc(c.want)) {
t.Errorf("%s from %s = %s, want %s", c.expr, c.from, got.Format("2006-01-02 15:04 Mon"), c.want)
}
}
}
func TestParseCronErrors(t *testing.T) {
for _, expr := range []string{"", "* * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "5-1 * * * *", "*/0 * * * *", "a * * * *"} {
if _, err := ParseCron(expr); err == nil {
t.Errorf("%q: expected an error", expr)
}
}
s, _ := ParseCron("0 0 30 2 *")
if !s.Next(time.Now()).IsZero() {
t.Error("Feb 30 should never run")
}
}