package main import ( "context" "errors" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/kami/maven/internal/ipc" ) // The page showed the storage envelope and the UTC instant (Vikunja #469). func TestReminderRowsUnwrapAndLocalise(t *testing.T) { fire := time.Date(2026, 8, 4, 18, 30, 0, 0, time.UTC) rows := reminderRows([]ipc.Reminder{{ ID: 17, CreatedTs: fire.Add(-time.Hour), FireTs: fire, Status: "pending", Payload: `{"text":"выпить таблетки"}`, }}) if len(rows) != 1 { t.Fatalf("rows = %d, want 1", len(rows)) } if rows[0].Text != "выпить таблетки" { t.Errorf("Text = %q, want the words without the envelope", rows[0].Text) } if want := fire.Local().Format("02 Jan 15:04"); rows[0].Fires != want { t.Errorf("Fires = %q, want %q", rows[0].Fires, want) } if strings.Contains(rows[0].Text, "{") { t.Errorf("Text still carries JSON: %q", rows[0].Text) } if rows[0].ID != 17 || !rows[0].CanCancel { t.Errorf("pending reminder action binding = %+v, want id 17 cancellable", rows[0]) } } func TestReminderRowsExposeBlockedDelivery(t *testing.T) { blocked := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC) rows := reminderRows([]ipc.Reminder{{ Status: "pending", Payload: `{"text":"позвонить врачу"}`, DeliveryBlockedTs: blocked, DeliveryBlockedError: "ntfy credentials rejected", }}) if len(rows) != 1 || rows[0].Status != "blocked" || rows[0].Detail != "ntfy credentials rejected" { t.Fatalf("blocked reminder is not visible: %+v", rows) } if !rows[0].CanCancel { t.Fatal("a blocked but still-pending reminder must remain cancellable") } } func TestReminderRowsShowTheCurrentRecurringOccurrence(t *testing.T) { original := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) next := time.Date(2026, 8, 16, 9, 0, 0, 0, time.UTC) rows := reminderRows([]ipc.Reminder{{ ID: 42, FireTs: original, NextFireTs: next, Cron: "0 9 * * *", Status: "pending", Payload: `{"text":"принять лекарство"}`, }}) if len(rows) != 1 || rows[0].Fires != next.Local().Format("02 Jan 15:04") { t.Fatalf("recurring fire = %+v, want current occurrence %s", rows, next) } if rows[0].Schedule != "recurring · 0 9 * * *" || !rows[0].CanCancel { t.Fatalf("recurring identity/action = %+v", rows[0]) } } func TestReminderRowsOnlyPendingCanCancel(t *testing.T) { rows := reminderRows([]ipc.Reminder{{Status: "cancelled"}, {Status: "fired"}}) for _, row := range rows { if row.CanCancel { t.Errorf("terminal row %+v exposed a cancel action", row) } } } // A payload that is not the envelope is his own words, so it is shown as it is. func TestReminderTextKeepsPlainPayload(t *testing.T) { for _, tc := range []struct{ in, want string }{ {`{"text":"позвонить маме"}`, "позвонить маме"}, {" полить цветы ", "полить цветы"}, {`{"body":"nope"}`, `{"body":"nope"}`}, {"", ""}, } { if got := reminderText(tc.in); got != tc.want { t.Errorf("reminderText(%q) = %q, want %q", tc.in, got, tc.want) } } } type reminderCore struct { ipc.UnimplementedCoreAPI reminders []ipc.Reminder pending []ipc.Reminder listErr error pendingErr error cancelErr error cancelID int64 } func (c *reminderCore) ListReminders(context.Context, int) ([]ipc.Reminder, error) { return c.reminders, c.listErr } func (c *reminderCore) ListPendingReminders(context.Context, int) ([]ipc.Reminder, error) { return c.pending, c.pendingErr } func (c *reminderCore) CancelReminder(_ context.Context, id int64) error { c.cancelID = id if c.cancelErr != nil { return c.cancelErr } for i := range c.reminders { if c.reminders[i].ID == id { c.reminders[i].Status = "cancelled" } } return nil } func reminderPost(action, id string) *http.Request { form := url.Values{"action": {action}, "id": {id}} req := httptest.NewRequest(http.MethodPost, "/reminders", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return req } func TestHandleRemindersCancel(t *testing.T) { core := &reminderCore{reminders: []ipc.Reminder{{ ID: 23, Status: "pending", Payload: `{"text":"позвонить врачу"}`, }}} rr := httptest.NewRecorder() handleReminders(rr, reminderPost("cancel", "23"), core) if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/reminders?cancelled=1" { t.Fatalf("status/location = %d %q, want 303 PRG", rr.Code, rr.Header().Get("Location")) } if core.cancelID != 23 { t.Fatalf("cancel id = %d, want 23", core.cancelID) } rr = httptest.NewRecorder() handleReminders(rr, httptest.NewRequest(http.MethodGet, "/reminders?cancelled=1", nil), core) if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "reminder cancelled") || strings.Contains(rr.Body.String(), "value=\"23\"") { t.Fatalf("redirect outcome rendered incorrectly: status=%d body=%s", rr.Code, rr.Body.String()) } } func TestHandleRemindersSuccessfulMutationDoesNotDependOnRefresh(t *testing.T) { core := &reminderCore{listErr: errors.New("offline"), pendingErr: errors.New("offline")} rr := httptest.NewRecorder() handleReminders(rr, reminderPost("cancel", "23"), core) if rr.Code != http.StatusSeeOther || core.cancelID != 23 { t.Fatalf("successful cancellation became refresh failure: status=%d id=%d body=%s", rr.Code, core.cancelID, rr.Body.String()) } } func TestHandleRemindersIncludesPendingRowsOutsideRecentWindow(t *testing.T) { old := ipc.Reminder{ID: 1, Status: "pending", Payload: `{"text":"old but pending"}`} recent := make([]ipc.Reminder, 50) for i := range recent { recent[i] = ipc.Reminder{ID: int64(i + 2), Status: "fired", Payload: `{"text":"history"}`} } core := &reminderCore{pending: []ipc.Reminder{old}, reminders: recent} rr := httptest.NewRecorder() handleReminders(rr, httptest.NewRequest(http.MethodGet, "/reminders", nil), core) if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "old but pending") || !strings.Contains(rr.Body.String(), `value="1"`) { t.Fatalf("old pending reminder is not reachable: status=%d body=%s", rr.Code, rr.Body.String()) } } func TestHandleRemindersRejectsMalformedPosts(t *testing.T) { for _, tc := range []struct { name string action string id string }{ {"unknown action", "delete", "23"}, {"missing id", "cancel", ""}, {"non-numeric id", "cancel", "twenty-three"}, {"non-positive id", "cancel", "0"}, } { t.Run(tc.name, func(t *testing.T) { core := &reminderCore{} rr := httptest.NewRecorder() handleReminders(rr, reminderPost(tc.action, tc.id), core) if rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body=%s", rr.Code, rr.Body.String()) } if core.cancelID != 0 { t.Fatalf("CancelReminder called with %d for malformed post", core.cancelID) } }) } } func TestHandleRemindersCancelErrors(t *testing.T) { for _, tc := range []struct { name string err error status int public string }{ {"missing", ipc.ErrReminderNotFound, http.StatusNotFound, "reminder not found"}, {"delivery started", ipc.ErrReminderInFlight, http.StatusConflict, "reminder delivery has already started"}, {"terminal", ipc.ErrReminderState, http.StatusConflict, "reminder is no longer pending"}, {"transport", errors.New("socket closed"), http.StatusBadGateway, "reminder cancellation failed"}, } { t.Run(tc.name, func(t *testing.T) { core := &reminderCore{cancelErr: tc.err} rr := httptest.NewRecorder() handleReminders(rr, reminderPost("cancel", "23"), core) if rr.Code != tc.status || !strings.Contains(rr.Body.String(), tc.public) { t.Fatalf("status/body = %d %q, want %d containing %q", rr.Code, rr.Body.String(), tc.status, tc.public) } if got := rr.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/problem+json") { t.Fatalf("content type = %q, want problem JSON", got) } }) } } func TestHandleRemindersMethodAndListErrors(t *testing.T) { t.Run("method", func(t *testing.T) { rr := httptest.NewRecorder() handleReminders(rr, httptest.NewRequest(http.MethodDelete, "/reminders", nil), &reminderCore{}) if rr.Code != http.StatusMethodNotAllowed { t.Fatalf("status = %d, want 405", rr.Code) } }) t.Run("pending list", func(t *testing.T) { rr := httptest.NewRecorder() handleReminders(rr, httptest.NewRequest(http.MethodGet, "/reminders", nil), &reminderCore{pendingErr: errors.New("offline")}) if rr.Code != http.StatusBadGateway || !strings.Contains(rr.Body.String(), "reminders unavailable") { t.Fatalf("status/body = %d %q, want 502 problem", rr.Code, rr.Body.String()) } }) t.Run("recent list", func(t *testing.T) { rr := httptest.NewRecorder() handleReminders(rr, httptest.NewRequest(http.MethodGet, "/reminders", nil), &reminderCore{listErr: errors.New("offline")}) if rr.Code != http.StatusBadGateway || !strings.Contains(rr.Body.String(), "reminders unavailable") { t.Fatalf("status/body = %d %q, want 502 problem", rr.Code, rr.Body.String()) } }) t.Run("successful outcome remains explicit when redirected refresh fails", func(t *testing.T) { rr := httptest.NewRecorder() handleReminders(rr, httptest.NewRequest(http.MethodGet, "/reminders?cancelled=1", nil), &reminderCore{listErr: errors.New("offline")}) if rr.Code != http.StatusBadGateway || !strings.Contains(rr.Body.String(), "reminder cancelled; refreshed list unavailable") { t.Fatalf("status/body = %d %q, want truthful refresh problem", rr.Code, rr.Body.String()) } }) }