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) } // Index 19, version 20: standing lists landed on the same number first // (Vikunja #453), so this one moved down one. if _, err := s.db.ExecContext(ctx, migrations[19]); err != nil { t.Fatalf("migration 20: %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) } }