feat(phase3): reminders, ICS feeds, templates and nightly backups
Reminders are absolute or relative ("10 minutes before"). A relative one
is resolved against the task in SQL, so moving a task moves its reminders
too. They are marked sent before delivery, not after: a reminder that
goes out unrecorded repeats every minute forever, which is worse than
one recorded that fails to send.
The calendar feed is a per-user token in the path, because Apple Calendar
and Google fetch with no credentials and no way to prompt for any. Tasks
go out as VTODO — the same component CalDAV will serve — and timed ones
also as VEVENT, since Google ignores VTODO entirely.
Templates store relative day offsets rather than dates, which is what
makes them reusable: an onboarding checklist is "day 1, day 3, day 14",
not three dates in March. Building a project from one is a single
transaction, so a failure cannot leave half a checklist.
Backups use SQLite's VACUUM INTO, not a file copy — a copy of a live
WAL database can miss recent writes or catch a page mid-update, and the
result looks like a database right up until it is needed. Rotation keeps
fourteen by count, not by age: a container that was off for a month must
not come back and delete every backup it has.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4d142b2fa7
commit
7e4c730049
@@ -0,0 +1,330 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/ics"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// handleICSFeed serves a user's calendar.
|
||||
//
|
||||
// Unauthenticated by necessity: Apple Calendar and Google fetch a URL with no
|
||||
// credentials and no way to prompt for any, so the token in the path *is* the
|
||||
// credential. That is why it is a token of its own — it can be rotated when a feed
|
||||
// URL ends up somewhere it should not, without signing anybody out.
|
||||
func (s *Server) handleICSFeed(w http.ResponseWriter, r *http.Request) {
|
||||
token := strings.TrimSuffix(chi.URLParam(r, "token"), ".ics")
|
||||
|
||||
user, err := s.db.UserByICSToken(r.Context(), token)
|
||||
if err != nil {
|
||||
// Not 401: a calendar client would show a password prompt nobody can
|
||||
// answer. A missing feed is a missing feed.
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
// Everything with a date, plus recently completed ones so a calendar does
|
||||
// not silently drop what was done this week.
|
||||
IncludeCompleted: true,
|
||||
Limit: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "ics feed", err)
|
||||
return
|
||||
}
|
||||
|
||||
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
|
||||
if err != nil {
|
||||
s.internal(w, "ics feed projects", err)
|
||||
return
|
||||
}
|
||||
names := map[string]string{}
|
||||
for _, p := range projects {
|
||||
names[p.ID] = p.Name
|
||||
}
|
||||
|
||||
cutoff := time.Now().AddDate(0, 0, -30)
|
||||
cal := ics.Calendar{Name: "verdande — " + user.Name, Domain: feedDomain(s.cfg.BaseURL)}
|
||||
|
||||
for _, t := range tasks {
|
||||
// A task with no date has no place in a calendar, and a completion from
|
||||
// months ago is noise in every client that shows it.
|
||||
if t.DueDate == "" {
|
||||
continue
|
||||
}
|
||||
if t.CompletedAt != nil && t.CompletedAt.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
cal.Tasks = append(cal.Tasks, ics.Task{
|
||||
ID: t.ID, Content: t.Content, Description: t.Description,
|
||||
ProjectName: names[t.ProjectID], DueDate: t.DueDate, DueDatetime: t.DueDatetime,
|
||||
DurationMin: t.DurationMin, Priority: t.Priority, Recurrence: t.RecurrenceRule,
|
||||
Completed: t.Completed(), CompletedAt: t.CompletedAt,
|
||||
CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `inline; filename="`+ics.Filename("verdande")+`"`)
|
||||
// A feed is per-person and changes constantly; a cached copy in a shared proxy
|
||||
// would be both stale and somebody else's.
|
||||
w.Header().Set("Cache-Control", "private, no-cache")
|
||||
w.Write([]byte(ics.Render(cal)))
|
||||
}
|
||||
|
||||
type feedResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// handleGetFeed returns the subscription URL, creating the token on first ask.
|
||||
func (s *Server) handleGetFeed(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
token, err := s.db.EnsureICSToken(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "ics token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
|
||||
}
|
||||
|
||||
// handleRotateFeed issues a new token, which immediately breaks every existing
|
||||
// subscription. That is the point: it is what somebody does when the old URL has
|
||||
// leaked.
|
||||
func (s *Server) handleRotateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
token, err := auth.NewToken()
|
||||
if err != nil {
|
||||
s.internal(w, "generate feed token", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.SetICSToken(r.Context(), user.ID, token); err != nil {
|
||||
s.internal(w, "set feed token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
|
||||
}
|
||||
|
||||
func feedDomain(baseURL string) string {
|
||||
host := stripScheme(baseURL)
|
||||
if i := strings.IndexAny(host, "/:"); i > 0 {
|
||||
host = host[:i]
|
||||
}
|
||||
if host == "" {
|
||||
return "verdande.local"
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// --- reminders --------------------------------------------------------------------
|
||||
|
||||
type reminderJSON struct {
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"task_id"`
|
||||
RemindAt string `json:"remind_at,omitempty"`
|
||||
OffsetMin *int `json:"offset_min,omitempty"`
|
||||
Sent bool `json:"sent"`
|
||||
}
|
||||
|
||||
type reminderRequest struct {
|
||||
// Exactly one of the two. An absolute moment, or minutes relative to the
|
||||
// task's due time — negative for "before", which is the only kind anybody
|
||||
// actually wants.
|
||||
RemindAt *string `json:"remind_at"`
|
||||
OffsetMin *int `json:"offset_min"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListReminders(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := chi.URLParam(r, "taskID")
|
||||
if _, err := store.TaskRole(r.Context(), s.db, taskID, userFrom(r.Context()).ID); err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
reminders, err := s.db.ListReminders(r.Context(), taskID)
|
||||
if err != nil {
|
||||
s.internal(w, "list reminders", err)
|
||||
return
|
||||
}
|
||||
out := make([]reminderJSON, 0, len(reminders))
|
||||
for _, rem := range reminders {
|
||||
j := reminderJSON{ID: rem.ID, TaskID: rem.TaskID, OffsetMin: rem.OffsetMin, Sent: rem.SentAt != nil}
|
||||
if !rem.RemindAt.IsZero() {
|
||||
j.RemindAt = rem.RemindAt.Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"reminders": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) {
|
||||
var req reminderRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
taskID := chi.URLParam(r, "taskID")
|
||||
user := userFrom(r.Context())
|
||||
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
if err != nil || !role.CanEdit() {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if (req.RemindAt == nil) == (req.OffsetMin == nil) {
|
||||
writeFieldErrors(w, map[string]string{
|
||||
"remind_at": "give either a time or an offset, not both",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var at *time.Time
|
||||
if req.RemindAt != nil {
|
||||
parsed, err := time.Parse(time.RFC3339, *req.RemindAt)
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"remind_at": "must be an RFC 3339 timestamp"})
|
||||
return
|
||||
}
|
||||
at = &parsed
|
||||
}
|
||||
|
||||
rem, err := s.db.CreateReminder(r.Context(), taskID, user.ID, at, req.OffsetMin)
|
||||
if err != nil {
|
||||
s.internal(w, "create reminder", err)
|
||||
return
|
||||
}
|
||||
|
||||
j := reminderJSON{ID: rem.ID, TaskID: rem.TaskID, OffsetMin: rem.OffsetMin}
|
||||
if at != nil {
|
||||
j.RemindAt = at.Format(time.RFC3339)
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, j)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteReminder(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteReminder(r.Context(), chi.URLParam(r, "reminderID"), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "delete reminder", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- templates ---------------------------------------------------------------------
|
||||
|
||||
type templateJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Color string `json:"color"`
|
||||
TaskCount int `json:"task_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func toTemplateJSON(t store.Template) templateJSON {
|
||||
return templateJSON{
|
||||
ID: t.ID, Name: t.Name, Description: t.Description, Color: t.Color,
|
||||
TaskCount: len(t.Body.Tasks), CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
templates, err := s.db.ListTemplates(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list templates", err)
|
||||
return
|
||||
}
|
||||
out := make([]templateJSON, 0, len(templates))
|
||||
for _, t := range templates {
|
||||
out = append(out, toTemplateJSON(t))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"templates": out})
|
||||
}
|
||||
|
||||
type saveTemplateRequest struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
var req saveTemplateRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
if req.ProjectID == "" {
|
||||
writeFieldErrors(w, map[string]string{"project_id": "required"})
|
||||
return
|
||||
}
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, req.ProjectID, user.ID, store.RoleViewer); err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
tpl, err := s.db.SaveProjectAsTemplate(r.Context(), req.ProjectID, user.ID,
|
||||
strings.TrimSpace(req.Name), strings.TrimSpace(req.Description))
|
||||
if err != nil {
|
||||
s.storeError(w, "save template", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toTemplateJSON(*tpl))
|
||||
}
|
||||
|
||||
type useTemplateRequest struct {
|
||||
Name string `json:"name"`
|
||||
// StartDate is day zero for every relative due date in the template. Defaults
|
||||
// to today, which is what "start this checklist now" means.
|
||||
StartDate string `json:"start_date"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
var req useTemplateRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
tpl, err := s.db.GetTemplate(r.Context(), user.ID, chi.URLParam(r, "templateID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "get template", err)
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now().In(userLocation(user.Timezone))
|
||||
if req.StartDate != "" {
|
||||
parsed, err := time.ParseInLocation("2006-01-02", req.StartDate, userLocation(user.Timezone))
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"start_date": "must be a date like 2026-03-15"})
|
||||
return
|
||||
}
|
||||
start = parsed
|
||||
}
|
||||
|
||||
project, err := s.db.CreateProjectFromTemplate(r.Context(), tpl, user.ID, strings.TrimSpace(req.Name), start)
|
||||
if err != nil {
|
||||
s.internal(w, "create from template", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, project.ID, "", "project.created", map[string]any{
|
||||
"name": project.Name, "from_template": tpl.Name,
|
||||
})
|
||||
writeJSON(w, http.StatusCreated, toProjectJSON(*project))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteTemplate(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "templateID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "delete template", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -67,6 +67,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// Docker both need this to answer before anyone has logged in.
|
||||
r.Get("/healthz", s.handleHealth)
|
||||
|
||||
// The calendar feed is outside /api/v1 and outside the session: a calendar
|
||||
// client cannot log in, so the token in the path is the whole credential.
|
||||
r.Get("/ics/{token}", s.handleICSFeed)
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Use(s.requireCSRF)
|
||||
|
||||
@@ -126,6 +130,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Delete("/{filterID}", s.handleDeleteFilter)
|
||||
})
|
||||
|
||||
r.Get("/feed", s.handleGetFeed)
|
||||
r.Post("/feed/rotate", s.handleRotateFeed)
|
||||
|
||||
r.Route("/templates", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTemplates)
|
||||
r.Post("/", s.handleSaveTemplate)
|
||||
r.Post("/{templateID}/use", s.handleUseTemplate)
|
||||
r.Delete("/{templateID}", s.handleDeleteTemplate)
|
||||
})
|
||||
|
||||
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
|
||||
|
||||
r.Route("/labels", func(r chi.Router) {
|
||||
r.Get("/", s.handleListLabels)
|
||||
r.Post("/", s.handleCreateLabel)
|
||||
@@ -184,6 +200,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/", s.handleGetTask)
|
||||
r.Patch("/", s.handleUpdateTask)
|
||||
r.Delete("/", s.handleDeleteTask)
|
||||
r.Get("/reminders", s.handleListReminders)
|
||||
r.Post("/reminders", s.handleCreateReminder)
|
||||
r.Post("/complete", s.handleCompleteTask)
|
||||
r.Post("/reopen", s.handleReopenTask)
|
||||
r.Post("/move", s.handleMoveTask)
|
||||
@@ -200,6 +218,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.router.ServeHTTP(w, r) }
|
||||
|
||||
// Mail and Hub are exposed so the background jobs can deliver through the same
|
||||
// sender and the same live connections the request path uses — a reminder should
|
||||
// arrive in an open tab exactly as a colleague's edit does.
|
||||
func (s *Server) Mail() *mail.Sender { return s.mail }
|
||||
func (s *Server) Hub() *realtime.Hub { return s.hub }
|
||||
|
||||
// handleHealth reports whether verdande can actually serve requests, which means
|
||||
// reaching the database — a process that is up but cannot read its own data is not
|
||||
// healthy, and answering 200 for it would keep a broken container in rotation.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -1006,3 +1008,303 @@ func TestDeletingALabelKeepsItsTasks(t *testing.T) {
|
||||
t.Errorf("labels = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- reminders, feeds and templates -----------------------------------------------
|
||||
|
||||
func TestReminders(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "møde", "due_date": userDate(t, 0), "due_time": "14:00",
|
||||
})
|
||||
taskID := task["id"].(string)
|
||||
|
||||
// Relative: ten minutes before whenever the task is due.
|
||||
offset := -10
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
|
||||
map[string]any{"offset_min": offset})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create relative reminder: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// Absolute.
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
|
||||
map[string]any{"remind_at": time.Now().Add(time.Hour).Format(time.RFC3339)})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create absolute reminder: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Both at once is not a reminder with two times; it is a mistake.
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders", map[string]any{
|
||||
"offset_min": offset, "remind_at": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("a reminder with both a time and an offset: status %d, want 422", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, list := ts.do(t, "GET", "/api/v1/tasks/"+taskID+"/reminders", nil)
|
||||
reminders, _ := list["reminders"].([]any)
|
||||
if len(reminders) != 2 {
|
||||
t.Fatalf("got %d reminders, want 2", len(reminders))
|
||||
}
|
||||
}
|
||||
|
||||
// A reminder that has come due is found by the sweep; one in the future is not,
|
||||
// and neither is one on a task that has already been done.
|
||||
func TestDueRemindersAreFoundOnce(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "husk"})
|
||||
taskID := task["id"].(string)
|
||||
|
||||
past := time.Now().Add(-time.Minute)
|
||||
future := time.Now().Add(time.Hour)
|
||||
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
|
||||
map[string]any{"remind_at": past.Format(time.RFC3339)})
|
||||
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
|
||||
map[string]any{"remind_at": future.Format(time.RFC3339)})
|
||||
|
||||
due, err := ts.db.DueReminders(t.Context(), time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(due) != 1 {
|
||||
t.Fatalf("got %d due reminders, want only the one in the past", len(due))
|
||||
}
|
||||
if due[0].TaskContent != "husk" {
|
||||
t.Errorf("the reminder does not carry the task's text: %+v", due[0])
|
||||
}
|
||||
|
||||
// Marking it sent takes it out of the sweep, so it cannot go out twice.
|
||||
if err := ts.db.MarkReminderSent(t.Context(), due[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := ts.db.DueReminders(t.Context(), time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(again) != 0 {
|
||||
t.Errorf("a sent reminder came round again: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
// A reminder for something already done is a small betrayal of the whole feature.
|
||||
func TestCompletedTasksDoNotRemind(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "gjort"})
|
||||
taskID := task["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
|
||||
map[string]any{"remind_at": time.Now().Add(-time.Minute).Format(time.RFC3339)})
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/complete", nil)
|
||||
|
||||
due, err := ts.db.DueReminders(t.Context(), time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(due) != 0 {
|
||||
t.Errorf("a completed task still reminded: %+v", due)
|
||||
}
|
||||
}
|
||||
|
||||
func TestICSFeed(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "møde med revisor", "due_date": userDate(t, 1), "due_time": "10:00",
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "uden dato"})
|
||||
|
||||
resp, body := ts.do(t, "GET", "/api/v1/feed", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get feed: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
feedURL, _ := body["url"].(string)
|
||||
if feedURL == "" {
|
||||
t.Fatal("no feed URL was returned")
|
||||
}
|
||||
|
||||
// The feed itself is fetched without a session, as a calendar client would.
|
||||
path := feedURL[strings.Index(feedURL, "/ics/"):]
|
||||
req, err := http.NewRequest("GET", ts.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer raw.Body.Close()
|
||||
|
||||
if raw.StatusCode != http.StatusOK {
|
||||
t.Fatalf("fetching the feed without a session: %d", raw.StatusCode)
|
||||
}
|
||||
if ct := raw.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/calendar") {
|
||||
t.Errorf("Content-Type = %q, want text/calendar", ct)
|
||||
}
|
||||
|
||||
content, _ := io.ReadAll(raw.Body)
|
||||
feed := string(content)
|
||||
if !strings.Contains(feed, "BEGIN:VCALENDAR") {
|
||||
t.Error("the feed is not an iCalendar document")
|
||||
}
|
||||
if !strings.Contains(feed, "SUMMARY:møde med revisor") {
|
||||
t.Error("the dated task is missing from the feed")
|
||||
}
|
||||
if strings.Contains(feed, "uden dato") {
|
||||
t.Error("a task with no date appeared in a calendar")
|
||||
}
|
||||
|
||||
// An invented token is a missing feed, not a prompt for credentials nobody
|
||||
// can answer.
|
||||
bad, err := (&http.Client{}).Get(ts.URL + "/ics/opfundet.ics")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer bad.Body.Close()
|
||||
if bad.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("an unknown feed token: status %d, want 404", bad.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Rotating the token is what somebody does when a feed URL has leaked, so the old
|
||||
// one has to stop working immediately.
|
||||
func TestRotatingTheFeedTokenBreaksTheOldURL(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, first := ts.do(t, "GET", "/api/v1/feed", nil)
|
||||
oldURL := first["url"].(string)
|
||||
|
||||
_, second := ts.do(t, "POST", "/api/v1/feed/rotate", nil)
|
||||
newURL := second["url"].(string)
|
||||
|
||||
if oldURL == newURL {
|
||||
t.Fatal("rotating produced the same URL")
|
||||
}
|
||||
|
||||
oldPath := oldURL[strings.Index(oldURL, "/ics/"):]
|
||||
resp, err := (&http.Client{}).Get(ts.URL + oldPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("the old feed URL still works: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectTemplates(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Onboarding"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
|
||||
map[string]any{"name": "Første uge"})
|
||||
sectionID := section["id"].(string)
|
||||
|
||||
_, parent := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "opret konti", "project_id": projectID, "section_id": sectionID,
|
||||
"priority": 1, "due_date": userDate(t, 0), "labels": []string{"it"},
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "udlever laptop", "project_id": projectID, "parent_id": parent["id"].(string),
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "opfølgning", "project_id": projectID, "due_date": userDate(t, 14),
|
||||
})
|
||||
|
||||
resp, tpl := ts.do(t, "POST", "/api/v1/templates", map[string]any{
|
||||
"project_id": projectID, "name": "Onboarding-skabelon",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("save template: %d %v", resp.StatusCode, tpl)
|
||||
}
|
||||
if tpl["task_count"] != float64(3) {
|
||||
t.Errorf("task_count = %v, want 3", tpl["task_count"])
|
||||
}
|
||||
|
||||
// Used a month later, the dates land relative to the new start.
|
||||
start := time.Now().AddDate(0, 1, 0).Format("2006-01-02")
|
||||
resp, created := ts.do(t, "POST", "/api/v1/templates/"+tpl["id"].(string)+"/use",
|
||||
map[string]any{"name": "Onboarding: Mette", "start_date": start})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("use template: %d %v", resp.StatusCode, created)
|
||||
}
|
||||
if created["name"] != "Onboarding: Mette" {
|
||||
t.Errorf("name = %v", created["name"])
|
||||
}
|
||||
|
||||
newID := created["id"].(string)
|
||||
_, list := ts.do(t, "GET", "/api/v1/tasks?project_id="+newID, nil)
|
||||
tasks, _ := list["tasks"].([]any)
|
||||
if len(tasks) != 3 {
|
||||
t.Fatalf("the new project has %d tasks, want 3", len(tasks))
|
||||
}
|
||||
|
||||
// Sections came across, sub-tasks kept their parent, and the spacing between
|
||||
// the dates survived even though the dates themselves did not.
|
||||
_, sections := ts.do(t, "GET", "/api/v1/projects/"+newID+"/sections", nil)
|
||||
if got, _ := sections["sections"].([]any); len(got) != 1 {
|
||||
t.Errorf("got %d sections, want 1", len(got))
|
||||
}
|
||||
|
||||
var withParent, dated int
|
||||
var earliest, latest string
|
||||
for _, raw := range tasks {
|
||||
task := raw.(map[string]any)
|
||||
if task["parent_id"] != nil && task["parent_id"] != "" {
|
||||
withParent++
|
||||
}
|
||||
if due, _ := task["due_date"].(string); due != "" {
|
||||
dated++
|
||||
if earliest == "" || due < earliest {
|
||||
earliest = due
|
||||
}
|
||||
if due > latest {
|
||||
latest = due
|
||||
}
|
||||
}
|
||||
}
|
||||
if withParent != 1 {
|
||||
t.Errorf("%d tasks have a parent, want 1", withParent)
|
||||
}
|
||||
if dated != 2 {
|
||||
t.Errorf("%d tasks have a date, want 2", dated)
|
||||
}
|
||||
if earliest != start {
|
||||
t.Errorf("the first task is due %q, want the stated start %q", earliest, start)
|
||||
}
|
||||
first, _ := time.Parse("2006-01-02", earliest)
|
||||
last, _ := time.Parse("2006-01-02", latest)
|
||||
if gap := int(last.Sub(first).Hours() / 24); gap != 14 {
|
||||
t.Errorf("the tasks are %d days apart, want the original 14", gap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplatesAreScopedToTheirOwner(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
|
||||
_, tpl := ts.do(t, "POST", "/api/v1/templates",
|
||||
map[string]any{"project_id": project["id"].(string)})
|
||||
tplID := tpl["id"].(string)
|
||||
|
||||
resp, _ := other.do(t, "POST", "/api/v1/templates/"+tplID+"/use", map[string]any{})
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("another user used the template: status %d", resp.StatusCode)
|
||||
}
|
||||
_, list := other.do(t, "GET", "/api/v1/templates", nil)
|
||||
if got, _ := list["templates"].([]any); len(got) != 0 {
|
||||
t.Errorf("another user's template list shows %d entries", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user