Push the week's plan to Google Calendar (Feature 16)
POST /api/v1/plans/{weekStart}/push-calendar writes one event per plan
day to the chosen calendar: "Dinner: {name}" from the household dinner
time for 60 minutes in the household zone, with the dinner name,
"Start by {time}" for crock-pot dinners and the
{MEALQUEST_PUBLIC_URL}/dinner/{id} link. Event ids are the lowercase
base32hex SHA-256 of "mealquest|{weekStart}|{date}"; each event is
inserted, and a 409 updates it instead.
gcal.Service.PushEvents does the Calendar writes under the account lock,
with the same redaction as listing. plan.Service.Push records a push only
when every event was written: the plan becomes synced with
calendar_synced_at and each day stores its event id. A partial failure
records nothing; a plan edited mid-push is 409 plan_changed.
A swap, or a redraft to another dinner, clears the day's
calendar_event_id, so the next push writes just those days; a push with
none pending writes every day again.
No calendar chosen is 409 no_calendar; a deleted or read-only chosen
calendar is 409 calendar_unavailable; a refused grant is 502
reconnect_required, leaving the connection as it is.
Maestro task 5885d2b1-cf06-44ff-abf3-242d7763c3c9.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,15 @@ package plan_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -18,9 +21,12 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
@@ -31,6 +37,9 @@ var (
|
||||
weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
|
||||
)
|
||||
|
||||
// publicURL is MEALQUEST_PUBLIC_URL in these tests.
|
||||
var publicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
|
||||
|
||||
func mustDate(s string) store.LocalDate {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
@@ -44,6 +53,9 @@ type env struct {
|
||||
st *store.Store
|
||||
runner *jobs.Runner
|
||||
model *llm.Fake
|
||||
// google is the fake Google the service pushes to through gcal.
|
||||
google *gcaltest.Google
|
||||
gcal *gcal.Service
|
||||
// ids are the seeded dinners' ids by name.
|
||||
ids map[string]string
|
||||
|
||||
@@ -55,7 +67,8 @@ type env struct {
|
||||
// America/New_York with dinner time 17:30, the Regulars below and Quest
|
||||
// log dinners Pad Thai and Shawarma Bowls, and an unstarted job runner
|
||||
// with two workers. It drafts through the real harness and draft_week with
|
||||
// the scripted e.model.
|
||||
// the scripted e.model, and pushes through gcal to e.google, which starts
|
||||
// unconnected.
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
@@ -111,7 +124,26 @@ func newEnv(t *testing.T) *env {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, logger)
|
||||
e.google = gcaltest.New(t, "https://meals.example.com"+gcal.CallbackPath)
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.gcal, err = gcal.NewService(st, box, gcal.Config{
|
||||
ClientID: gcaltest.ClientID,
|
||||
ClientSecret: gcaltest.ClientSecret,
|
||||
PublicURL: publicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
Endpoint: e.google.Endpoint(),
|
||||
RevokeURL: e.google.RevokeURL(),
|
||||
CalendarAPI: e.google.CalendarAPI(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, publicURL, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -781,12 +813,26 @@ func TestDraftOverSyncedOrChangedPlanNeedsConfirm(t *testing.T) {
|
||||
if got := dayNames(p); !slices.Equal(got, reversed) {
|
||||
t.Errorf("days = %v, want %v", got, reversed)
|
||||
}
|
||||
var events int
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM plan_days WHERE calendar_event_id = 'ev-' || date`).Scan(&events); err != nil {
|
||||
// Reversed, only Thursday keeps its dinner, so only Thursday
|
||||
// keeps its event id: Calendar no longer shows the others as
|
||||
// planned.
|
||||
rows, err := e.st.Reader.QueryContext(t.Context(), `SELECT date FROM plan_days WHERE calendar_event_id = 'ev-' || date ORDER BY date`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if events != 7 {
|
||||
t.Errorf("%d days kept their calendar event id, want 7", events)
|
||||
var kept []string
|
||||
for rows.Next() {
|
||||
var date string
|
||||
if err := rows.Scan(&date); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept = append(kept, date)
|
||||
}
|
||||
if err := errors.Join(rows.Err(), rows.Close()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(kept, []string{"2026-10-15"}) {
|
||||
t.Errorf("days that kept their calendar event id = %v, want only the unchanged 2026-10-15", kept)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user