a820a95ebb
Routines accepted before Vikunja #366 carry accepted_ts NULL and a live reminder row. The tick loop reads accepted_ts to decide when a routine is next due, so those rows have been silent since the fix landed, while the reminder they still point at keeps firing on its own schedule. Migration #19 cancels that reminder first, then dates the acceptance from created_ts and lets the reminder id go. Order matters: the second update clears the id the first one needs.
150 lines
5.2 KiB
Go
150 lines
5.2 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func userVersion(t *testing.T, s *Store) int {
|
||
t.Helper()
|
||
var v int
|
||
if err := s.db.QueryRowContext(context.Background(), "PRAGMA user_version").Scan(&v); err != nil {
|
||
t.Fatalf("read user_version: %v", err)
|
||
}
|
||
return v
|
||
}
|
||
|
||
func TestMigrateAppliesOnceAndIsIdempotent(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
|
||
// The 1 migration in the built-in slice (tools scope) was applied on Open.
|
||
startVer := len(migrations)
|
||
if v := userVersion(t, s); v != startVer {
|
||
t.Fatalf("fresh DB user_version = %d, want %d", v, startVer)
|
||
}
|
||
|
||
// Append a fake migration and run it: creates a throwaway table, bumps by 1.
|
||
migrations = append(migrations, `CREATE TABLE migrate_probe (id INTEGER PRIMARY KEY)`)
|
||
t.Cleanup(func() { migrations = migrations[:len(migrations)-1] })
|
||
|
||
if err := migrate(ctx, s.db); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
want := startVer + 1
|
||
if v := userVersion(t, s); v != want {
|
||
t.Fatalf("after migrate user_version = %d, want %d", v, want)
|
||
}
|
||
if _, err := s.db.ExecContext(ctx, "INSERT INTO migrate_probe DEFAULT VALUES"); err != nil {
|
||
t.Fatalf("probe table not created: %v", err)
|
||
}
|
||
|
||
// Second run is a no-op — re-running the CREATE would error (no IF NOT EXISTS).
|
||
if err := migrate(ctx, s.db); err != nil {
|
||
t.Fatalf("migrate second run not idempotent: %v", err)
|
||
}
|
||
if v := userVersion(t, s); v != want {
|
||
t.Fatalf("after re-migrate user_version = %d, want %d", v, want)
|
||
}
|
||
}
|
||
|
||
// Migration #18 clears the calendar keys written while safeKey dropped
|
||
// Cyrillic. Those rows are indistinguishable from real events on read, so
|
||
// leaving them would recite one meeting as several (Vikunja #443).
|
||
func TestCollapsedCalendarKeysAreDropped(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
|
||
rows := []string{
|
||
"calendar_event_20260804_--", // "Встреча с Аней" under the old rule
|
||
"calendar_event_20260804_", // a one-word Russian summary
|
||
"calendar_event_20260804_Встреча-с-Аней", // the new format
|
||
"calendar_event_20260804_Standup", // an ASCII summary, always fine
|
||
}
|
||
for _, key := range rows {
|
||
if _, err := s.db.ExecContext(ctx,
|
||
`INSERT INTO facts (ts, kind, key, value, source, confidence) VALUES (0, 'env', ?, 'x', 'poll:caldav', 1.0)`,
|
||
key); err != nil {
|
||
t.Fatalf("seed %q: %v", key, err)
|
||
}
|
||
}
|
||
if _, err := s.db.ExecContext(ctx, migrations[17]); err != nil {
|
||
t.Fatalf("migration 18: %v", err)
|
||
}
|
||
|
||
var got int
|
||
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM facts WHERE key LIKE 'calendar_event_%'`).Scan(&got); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got != 2 {
|
||
t.Fatalf("%d calendar rows left, want the 2 that identify their event", got)
|
||
}
|
||
}
|
||
|
||
// TestStuckRoutinesAreBackfilled — routines accepted before the fire-forever
|
||
// fix have accepted_ts NULL and a live reminder, so the tick loop skips them
|
||
// and they have been silent ever since (Vikunja #377). The migration touches
|
||
// live reminders, which is why it is tested against a real store.
|
||
func TestStuckRoutinesAreBackfilled(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
created := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC)
|
||
|
||
rem, err := s.CreateReminder(ctx, created.Add(time.Hour), "полить цветы", "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
healthy, err := s.CreateReminder(ctx, created.Add(2*time.Hour), "не трогать", "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := s.db.ExecContext(ctx,
|
||
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts, reminder_id, accepted_ts)
|
||
VALUES ('water', 'plants', 7, 'accepted', ?, ?, NULL)`,
|
||
created.UnixMilli(), rem); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// An already-healthy accepted row, and a still-open proposal: neither is
|
||
// this migration's business.
|
||
if _, err := s.db.ExecContext(ctx,
|
||
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts, accepted_ts)
|
||
VALUES ('feed', 'cat', 1, 'accepted', ?, ?)`,
|
||
created.UnixMilli(), created.UnixMilli()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if _, err := s.db.ExecContext(ctx, migrations[18]); err != nil {
|
||
t.Fatalf("migration 19: %v", err)
|
||
}
|
||
|
||
accepted, err := s.ListAcceptedRoutines(ctx)
|
||
if err != nil || len(accepted) != 2 {
|
||
t.Fatalf("ListAcceptedRoutines = %d rows, err=%v, want 2", len(accepted), err)
|
||
}
|
||
stuck := accepted[0]
|
||
if stuck.Object != "plants" {
|
||
stuck = accepted[1]
|
||
}
|
||
if stuck.AcceptedTs == nil || !stuck.AcceptedTs.Equal(created) {
|
||
t.Fatalf("accepted_ts = %v, want the creation time", stuck.AcceptedTs)
|
||
}
|
||
if stuck.ReminderID != nil {
|
||
t.Fatalf("reminder_id = %v, want it let go", stuck.ReminderID)
|
||
}
|
||
// The reminder it was holding is cancelled, and nothing else is.
|
||
var status string
|
||
if err := s.db.QueryRowContext(ctx, `SELECT status FROM reminders WHERE id = ?`, rem).Scan(&status); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if status != ReminderCancelled {
|
||
t.Fatalf("linked reminder status = %q, want cancelled", status)
|
||
}
|
||
if err := s.db.QueryRowContext(ctx, `SELECT status FROM reminders WHERE id = ?`, healthy).Scan(&status); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if status != "pending" {
|
||
t.Fatalf("unrelated reminder status = %q, want it untouched", status)
|
||
}
|
||
}
|