Phase 6: write important school events to the Home Assistant calendar
Add the Home Assistant REST calendar client and the idempotent sync that writes important school events to Sam's Home Assistant calendar (FR-8). The school stays read-only; the only write is to the calendar (rule 7). - internal/calendar: HA REST client (states, create_event, get_events, delete_event) and the sync that selects important future events, adopts an existing event by its description marker, records the HA event id on the item, and records every attempt. - internal/store: migration 003 (items.ha_event_uid, items.ha_pushed_at and the append-only calendar_pushes table), candidates, mark-synced and the push log. - internal/parse: a school event is important; a routine class period is not. - cmd/school: `school calendar entities|status|push|events`. - web: an "Add to calendar" item action and a fail-loud calendar banner, so a failed write is never swallowed (FR-12). - scripts/calendar-push.sh, Makefile target, env example, docs and tests. The target is configurable (SCHOOL_HA_CALENDAR_ENTITY). Home Assistant has no non-Google writable calendar today, so the entity is asked on AUD-110. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
393
internal/calendar/ha.go
Normal file
393
internal/calendar/ha.go
Normal file
@@ -0,0 +1,393 @@
|
||||
// Package calendar writes important school events to Sam's Home Assistant
|
||||
// calendar (AGENTS.md §4 FR-8, §8, §11 Phase 6). It talks to the Home Assistant
|
||||
// REST API only; the school itself stays read-only. The token lives in the
|
||||
// environment and is never logged.
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
|
||||
)
|
||||
|
||||
// ErrorID returns the Home Assistant error id from a failed call, plus the
|
||||
// HTTP status. It exists so a caller can tell an expired token from a missing
|
||||
// entity without parsing a message.
|
||||
type Error struct {
|
||||
// Status is the HTTP status code, or 0 for a transport error.
|
||||
Status int
|
||||
// ID is the Home Assistant error id such as "unauthorized" or "not_found".
|
||||
ID string
|
||||
// Message is the human-readable detail.
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
switch {
|
||||
case e.ID != "" && e.Message != "":
|
||||
return fmt.Sprintf("home assistant %s: %s", e.ID, e.Message)
|
||||
case e.ID != "":
|
||||
return "home assistant: " + e.ID
|
||||
case e.Message != "":
|
||||
return "home assistant: " + e.Message
|
||||
default:
|
||||
return "home assistant call failed"
|
||||
}
|
||||
}
|
||||
|
||||
// Client is a Home Assistant REST client for one calendar entity.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
entity string
|
||||
http *http.Client
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New builds a client from configuration. It fails loudly when the target is
|
||||
// incomplete, so the caller never silently writes nowhere.
|
||||
func New(cfg config.CalendarConfig, log *slog.Logger) (*Client, error) {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
if missing := cfg.Missing(); len(missing) > 0 {
|
||||
return nil, fmt.Errorf("home assistant calendar is not configured: set %s", strings.Join(missing, ", "))
|
||||
}
|
||||
if _, err := url.Parse(cfg.BaseURL); err != nil {
|
||||
return nil, fmt.Errorf("SCHOOL_HA_URL %q: %w", cfg.BaseURL, err)
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 20 * time.Second
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
if cfg.Insecure {
|
||||
// Internal CA only (the .lab Caddy cert). Never enabled by default.
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
token: cfg.Token,
|
||||
entity: cfg.EntityID,
|
||||
http: &http.Client{Timeout: timeout, Transport: transport},
|
||||
log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Entity is the configured calendar entity id.
|
||||
func (c *Client) Entity() string { return c.entity }
|
||||
|
||||
// CalendarEntity is one calendar.* entity reported by Home Assistant.
|
||||
type CalendarEntity struct {
|
||||
EntityID string
|
||||
Name string
|
||||
State string
|
||||
// Features is the raw supported_features bit field.
|
||||
Features int
|
||||
}
|
||||
|
||||
// CanWrite reports whether the entity advertises the create-event feature
|
||||
// (Home Assistant CalendarEntityFeature.CREATE_EVENT = 1).
|
||||
func (e CalendarEntity) CanWrite() bool { return e.Features&1 != 0 }
|
||||
|
||||
// Healthy verifies the base URL and the token with GET /api/.
|
||||
func (c *Client) Healthy(ctx context.Context) error {
|
||||
var out struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodGet, "/api/", nil, &out); err != nil {
|
||||
return fmt.Errorf("home assistant ping: %w", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(out.Message), "running") {
|
||||
return fmt.Errorf("home assistant ping: unexpected reply %q", out.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calendars lists every calendar entity, so the operator can pick one. It is
|
||||
// the discovery step behind `school calendar entities`.
|
||||
func (c *Client) Calendars(ctx context.Context) ([]CalendarEntity, error) {
|
||||
var states []struct {
|
||||
EntityID string `json:"entity_id"`
|
||||
State string `json:"state"`
|
||||
Attributes struct {
|
||||
FriendlyName string `json:"friendly_name"`
|
||||
SupportedFeatures int `json:"supported_features"`
|
||||
} `json:"attributes"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodGet, "/api/states", nil, &states); err != nil {
|
||||
return nil, fmt.Errorf("home assistant states: %w", err)
|
||||
}
|
||||
var out []CalendarEntity
|
||||
for _, s := range states {
|
||||
if !strings.HasPrefix(s.EntityID, "calendar.") {
|
||||
continue
|
||||
}
|
||||
name := s.Attributes.FriendlyName
|
||||
if name == "" {
|
||||
name = s.EntityID
|
||||
}
|
||||
out = append(out, CalendarEntity{
|
||||
EntityID: s.EntityID,
|
||||
Name: name,
|
||||
State: s.State,
|
||||
Features: s.Attributes.SupportedFeatures,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].EntityID < out[j].EntityID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Event is one calendar event. UID is Home Assistant's id, when it reports one.
|
||||
type Event struct {
|
||||
UID string `json:"uid,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Start *time.Time `json:"-"`
|
||||
End *time.Time `json:"-"`
|
||||
StartRaw string `json:"-"`
|
||||
EndRaw string `json:"-"`
|
||||
}
|
||||
|
||||
// CreateEvent writes one event with calendar.create_event and returns the Home
|
||||
// Assistant event id when the service reports one. It asks for the service
|
||||
// response, so the id can be stored on the item and a re-run cannot duplicate
|
||||
// the event.
|
||||
func (c *Client) CreateEvent(ctx context.Context, ev Event) (string, error) {
|
||||
if strings.TrimSpace(ev.Summary) == "" {
|
||||
return "", errors.New("calendar: event summary is empty")
|
||||
}
|
||||
if ev.Start == nil || ev.End == nil {
|
||||
return "", errors.New("calendar: event start and end are required")
|
||||
}
|
||||
body := map[string]any{
|
||||
"entity_id": c.entity,
|
||||
"summary": ev.Summary,
|
||||
"start_date_time": ev.Start.Format(time.RFC3339),
|
||||
"end_date_time": ev.End.Format(time.RFC3339),
|
||||
}
|
||||
if ev.Description != "" {
|
||||
body["description"] = ev.Description
|
||||
}
|
||||
if ev.Location != "" {
|
||||
body["location"] = ev.Location
|
||||
}
|
||||
|
||||
resp, err := c.callService(ctx, "calendar", "create_event", body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return serviceUID(resp, c.entity), nil
|
||||
}
|
||||
|
||||
// DeleteEvent removes an event by its Home Assistant uid. It exists so a test
|
||||
// event can be cleaned up, and so a wrong write can be undone.
|
||||
func (c *Client) DeleteEvent(ctx context.Context, uid string) error {
|
||||
if strings.TrimSpace(uid) == "" {
|
||||
return errors.New("calendar: delete needs a uid")
|
||||
}
|
||||
_, err := c.callService(ctx, "calendar", "delete_event", map[string]any{
|
||||
"entity_id": c.entity,
|
||||
"uid": uid,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Events lists the events on the calendar between start and end (exclusive).
|
||||
// The sync uses it to see whether a marker is already present, so a re-run is
|
||||
// idempotent even when Home Assistant reports no uid.
|
||||
func (c *Client) Events(ctx context.Context, start, end time.Time) ([]Event, error) {
|
||||
resp, err := c.callService(ctx, "calendar", "get_events", map[string]any{
|
||||
"entity_id": c.entity,
|
||||
"start_date_time": start.Format(time.RFC3339),
|
||||
"end_date_time": end.Format(time.RFC3339),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return serviceEvents(resp, c.entity), nil
|
||||
}
|
||||
|
||||
// ---------- transport ----------
|
||||
|
||||
// callService posts to /api/services/<domain>/<service> with return_response,
|
||||
// so a service that reports data (the created event id) answers with it.
|
||||
func (c *Client) callService(ctx context.Context, domain, service string, body map[string]any) (map[string]any, error) {
|
||||
path := fmt.Sprintf("/api/services/%s/%s?return_response", domain, service)
|
||||
var out map[string]any
|
||||
if err := c.do(ctx, http.MethodPost, path, body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// do performs one authenticated request. A non-2xx answer becomes an *Error
|
||||
// carrying the Home Assistant error id, so the caller can act (and report) with
|
||||
// the real reason instead of a bare status code.
|
||||
func (c *Client) do(ctx context.Context, method, path string, body any, out any) error {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode request: %w", err)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return &Error{Message: fmt.Sprintf("request to %s failed: %v", path, err)}
|
||||
}
|
||||
defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return &Error{Status: resp.StatusCode, Message: "reading the reply failed: " + err.Error()}
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return haError(resp.StatusCode, raw)
|
||||
}
|
||||
if out == nil || len(bytes.TrimSpace(raw)) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return &Error{Status: resp.StatusCode, Message: fmt.Sprintf("unexpected reply body: %v", err)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// haError turns a Home Assistant error body into an *Error.
|
||||
func haError(status int, raw []byte) *Error {
|
||||
var shape struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &shape)
|
||||
msg := shape.Error.Message
|
||||
if msg == "" {
|
||||
msg = shape.Message
|
||||
}
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(string(raw))
|
||||
}
|
||||
return &Error{Status: status, ID: shape.Error.Code, Message: msg}
|
||||
}
|
||||
|
||||
// serviceUID digs the created event id out of a service response. Home
|
||||
// Assistant answers with {"service_response": {"calendar.x": {"uid": "..."}}};
|
||||
// some integrations nest the event under "result" or "event", so all three are
|
||||
// tried. An empty result is not an error: the description marker keeps the
|
||||
// write idempotent.
|
||||
func serviceUID(resp map[string]any, entity string) string {
|
||||
for _, payload := range servicePayloads(resp, entity) {
|
||||
if uid, ok := payload["uid"].(string); ok && uid != "" {
|
||||
return uid
|
||||
}
|
||||
for _, key := range []string{"result", "event"} {
|
||||
if nested, ok := payload[key].(map[string]any); ok {
|
||||
if uid, ok := nested["uid"].(string); ok && uid != "" {
|
||||
return uid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// serviceEvents reads the event list out of a calendar.get_events response.
|
||||
func serviceEvents(resp map[string]any, entity string) []Event {
|
||||
var out []Event
|
||||
for _, payload := range servicePayloads(resp, entity) {
|
||||
raw, ok := payload["events"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ev := Event{
|
||||
UID: str(m["uid"]),
|
||||
Summary: str(m["summary"]),
|
||||
Description: str(m["description"]),
|
||||
Location: str(m["location"]),
|
||||
}
|
||||
// Home Assistant reports a date or a date-time; try date-time first.
|
||||
ev.Start, ev.StartRaw = parseHATime(m, "start")
|
||||
ev.End, ev.EndRaw = parseHATime(m, "end")
|
||||
out = append(out, ev)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// servicePayloads returns the per-entity payload in a service response,
|
||||
// tolerating both the wrapped ({"service_response": {...}}) and the bare
|
||||
// shape. The entity key wins; other keys are only a fallback when Home
|
||||
// Assistant renamed it.
|
||||
func servicePayloads(resp map[string]any, entity string) []map[string]any {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
root := resp
|
||||
if sr, ok := resp["service_response"].(map[string]any); ok {
|
||||
root = sr
|
||||
}
|
||||
if payload, ok := root[entity].(map[string]any); ok {
|
||||
return []map[string]any{payload}
|
||||
}
|
||||
var out []map[string]any
|
||||
for _, v := range root {
|
||||
if payload, ok := v.(map[string]any); ok {
|
||||
out = append(out, payload)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseHATime(m map[string]any, prefix string) (*time.Time, string) {
|
||||
if raw := str(m[prefix+"_date_time"]); raw != "" {
|
||||
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
return &t, raw
|
||||
}
|
||||
}
|
||||
if raw := str(m[prefix+"_date"]); raw != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", raw, time.Local); err == nil {
|
||||
return &t, raw
|
||||
}
|
||||
return nil, raw
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func str(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
311
internal/calendar/ha_test.go
Normal file
311
internal/calendar/ha_test.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
|
||||
)
|
||||
|
||||
// fakeHA is a minimal Home Assistant REST API for the calendar tests. It
|
||||
// records what the client sent, so a test can prove the exact service calls.
|
||||
type fakeHA struct {
|
||||
*httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
entity string
|
||||
events []map[string]any
|
||||
createCalls int
|
||||
getCalls int
|
||||
failCreate bool
|
||||
returnUID bool
|
||||
lastCreate map[string]any
|
||||
lastGet map[string]any
|
||||
}
|
||||
|
||||
func newFakeHA(t *testing.T, entity string) *fakeHA {
|
||||
t.Helper()
|
||||
f := &fakeHA{entity: entity, returnUID: true}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !f.authOK(w, r) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"message": "API running."})
|
||||
})
|
||||
mux.HandleFunc("GET /api/states", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !f.authOK(w, r) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, []map[string]any{
|
||||
{"entity_id": f.entity, "state": "off", "attributes": map[string]any{
|
||||
"friendly_name": "School", "supported_features": 3,
|
||||
}},
|
||||
{"entity_id": "calendar.holidays", "state": "off", "attributes": map[string]any{
|
||||
"friendly_name": "Holidays",
|
||||
}},
|
||||
{"entity_id": "sensor.temperature", "state": "21", "attributes": map[string]any{}},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("POST /api/services/calendar/create_event", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !f.authOK(w, r) {
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.createCalls++
|
||||
f.lastCreate = body
|
||||
if f.failCreate {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
writeJSON(w, map[string]any{"error": map[string]any{"code": "unknown_error", "message": "calendar is busy"}})
|
||||
return
|
||||
}
|
||||
uid := ""
|
||||
if f.returnUID {
|
||||
uid = "uid-" + str(body["summary"])
|
||||
}
|
||||
f.events = append(f.events, map[string]any{
|
||||
"uid": uid,
|
||||
"summary": body["summary"],
|
||||
"description": body["description"],
|
||||
"start_date_time": body["start_date_time"],
|
||||
"end_date_time": body["end_date_time"],
|
||||
})
|
||||
writeJSON(w, map[string]any{
|
||||
"changed_entities": map[string]any{},
|
||||
"service_response": map[string]any{f.entity: map[string]any{"uid": uid}},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("POST /api/services/calendar/get_events", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !f.authOK(w, r) {
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.getCalls++
|
||||
f.lastGet = body
|
||||
evs := f.events
|
||||
if evs == nil {
|
||||
evs = []map[string]any{}
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"service_response": map[string]any{f.entity: map[string]any{"events": evs}},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("POST /api/services/calendar/delete_event", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !f.authOK(w, r) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"service_response": map[string]any{}})
|
||||
})
|
||||
f.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(f.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeHA) authOK(w http.ResponseWriter, r *http.Request) bool {
|
||||
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer test-token") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
writeJSON(w, map[string]any{"message": "Unauthorized"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (f *fakeHA) created() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.createCalls
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func testClient(t *testing.T, baseURL, entity string) *Client {
|
||||
t.Helper()
|
||||
c, err := New(config.CalendarConfig{
|
||||
BaseURL: baseURL,
|
||||
Token: "test-token",
|
||||
EntityID: entity,
|
||||
Timeout: 5 * time.Second,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestClientRejectsIncompleteConfig(t *testing.T) {
|
||||
if _, err := New(config.CalendarConfig{}, nil); err == nil {
|
||||
t.Fatal("New accepted an empty config")
|
||||
} else if !strings.Contains(err.Error(), "SCHOOL_HA_URL") {
|
||||
t.Errorf("error does not name the missing variable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthyAndCalendars(t *testing.T) {
|
||||
ha := newFakeHA(t, "calendar.school")
|
||||
c := testClient(t, ha.URL, "calendar.school")
|
||||
|
||||
if err := c.Healthy(context.Background()); err != nil {
|
||||
t.Fatalf("Healthy: %v", err)
|
||||
}
|
||||
entities, err := c.Calendars(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Calendars: %v", err)
|
||||
}
|
||||
if len(entities) != 2 {
|
||||
t.Fatalf("want 2 calendar entities, got %d (%v)", len(entities), entities)
|
||||
}
|
||||
if entities[0].EntityID != "calendar.holidays" || entities[1].EntityID != "calendar.school" {
|
||||
t.Errorf("entities not sorted: %v", entities)
|
||||
}
|
||||
if !entities[1].CanWrite() {
|
||||
t.Error("calendar.school should advertise CREATE_EVENT")
|
||||
}
|
||||
if entities[0].CanWrite() {
|
||||
t.Error("calendar.holidays should read-only")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthRejectsBadToken(t *testing.T) {
|
||||
ha := newFakeHA(t, "calendar.school")
|
||||
c, err := New(config.CalendarConfig{
|
||||
BaseURL: ha.URL, Token: "wrong", EntityID: "calendar.school", Timeout: 5 * time.Second,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if err := c.Healthy(context.Background()); err == nil {
|
||||
t.Fatal("Healthy accepted a bad token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEventReturnsUID(t *testing.T) {
|
||||
ha := newFakeHA(t, "calendar.school")
|
||||
c := testClient(t, ha.URL, "calendar.school")
|
||||
|
||||
start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC)
|
||||
uid, err := c.CreateEvent(context.Background(), Event{
|
||||
Summary: "Harry — End of Year Senior Music Concert",
|
||||
Start: &start,
|
||||
End: ptrTime(start.Add(time.Hour)),
|
||||
Description: "HALL\n\nsource: " + Marker(29),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEvent: %v", err)
|
||||
}
|
||||
if uid == "" {
|
||||
t.Fatal("CreateEvent returned no uid")
|
||||
}
|
||||
created := ha.lastCreate
|
||||
if got := str(created["entity_id"]); got != "calendar.school" {
|
||||
t.Errorf("entity_id = %q", got)
|
||||
}
|
||||
if got := str(created["start_date_time"]); got != "2026-10-15T18:00:00Z" {
|
||||
t.Errorf("start_date_time = %q", got)
|
||||
}
|
||||
if got := str(created["end_date_time"]); got != "2026-10-15T19:00:00Z" {
|
||||
t.Errorf("end_date_time = %q", got)
|
||||
}
|
||||
if !strings.Contains(str(created["description"]), Marker(29)) {
|
||||
t.Errorf("description lost the marker: %q", created["description"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEventWithoutUIDIsNotAnError(t *testing.T) {
|
||||
ha := newFakeHA(t, "calendar.school")
|
||||
ha.returnUID = false
|
||||
c := testClient(t, ha.URL, "calendar.school")
|
||||
|
||||
start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC)
|
||||
uid, err := c.CreateEvent(context.Background(), Event{Summary: "x", Start: &start, End: ptrTime(start)})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEvent: %v", err)
|
||||
}
|
||||
if uid != "" {
|
||||
t.Errorf("want no uid, got %q", uid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEventErrorCarriesHomeAssistantCode(t *testing.T) {
|
||||
ha := newFakeHA(t, "calendar.school")
|
||||
ha.failCreate = true
|
||||
c := testClient(t, ha.URL, "calendar.school")
|
||||
|
||||
start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC)
|
||||
_, err := c.CreateEvent(context.Background(), Event{Summary: "x", Start: &start, End: ptrTime(start)})
|
||||
if err == nil {
|
||||
t.Fatal("CreateEvent ignored a 500")
|
||||
}
|
||||
haErr, ok := err.(*Error)
|
||||
if !ok {
|
||||
t.Fatalf("want *Error, got %T", err)
|
||||
}
|
||||
if haErr.Status != http.StatusInternalServerError || haErr.ID != "unknown_error" {
|
||||
t.Errorf("error = %+v", haErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "calendar is busy") {
|
||||
t.Errorf("error loses the message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsReadBack(t *testing.T) {
|
||||
ha := newFakeHA(t, "calendar.school")
|
||||
c := testClient(t, ha.URL, "calendar.school")
|
||||
|
||||
start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC)
|
||||
if _, err := c.CreateEvent(context.Background(), Event{
|
||||
Summary: "Concert", Start: &start, End: ptrTime(start.Add(time.Hour)),
|
||||
Description: "source: " + Marker(29),
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateEvent: %v", err)
|
||||
}
|
||||
events, err := c.Events(context.Background(), start.Add(-time.Hour), start.Add(24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("Events: %v", err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("want 1 event, got %d", len(events))
|
||||
}
|
||||
if events[0].Summary != "Concert" || !strings.Contains(events[0].Description, Marker(29)) {
|
||||
t.Errorf("event = %+v", events[0])
|
||||
}
|
||||
if events[0].Start == nil || !events[0].Start.Equal(start) {
|
||||
t.Errorf("start = %v, want %v", events[0].Start, start)
|
||||
}
|
||||
body := ha.lastGet
|
||||
if got := str(body["start_date_time"]); got == "" {
|
||||
t.Error("get_events did not send a start window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMarker(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"source: [compass-nhs:29]": "[compass-nhs:29]",
|
||||
"line\nsource: [compass-nhs:7]\nmore": "[compass-nhs:7]",
|
||||
"no marker here": "",
|
||||
"[compass-nhs:abc]": "",
|
||||
"[compass-nhs:]": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := findMarker(in); got != want {
|
||||
t.Errorf("findMarker(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
275
internal/calendar/sync.go
Normal file
275
internal/calendar/sync.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
|
||||
)
|
||||
|
||||
// DefaultDuration is how long a school event is blocked out when Compass gives
|
||||
// only a start time. The week grid stores a start, not an end.
|
||||
const DefaultDuration = time.Hour
|
||||
|
||||
// Marker builds the idempotency marker stored in an event's description. It
|
||||
// carries the SQLite item id, so a re-run can recognise its own event even when
|
||||
// Home Assistant reports no event id.
|
||||
func Marker(itemID int64) string {
|
||||
return fmt.Sprintf("[compass-nhs:%d]", itemID)
|
||||
}
|
||||
|
||||
// ItemMarker is the full description tag written to the calendar.
|
||||
func ItemMarker(itemID int64) string { return "source: " + Marker(itemID) }
|
||||
|
||||
// Options narrows one sync run.
|
||||
type Options struct {
|
||||
// ChildID limits the run to one child (nil means all children).
|
||||
ChildID *int64
|
||||
// ItemID pushes exactly one item (used by the item screen).
|
||||
ItemID *int64
|
||||
// Limit caps how many events are written in one run (0 means no cap).
|
||||
Limit int
|
||||
// DryRun selects the candidates and reports them without writing.
|
||||
DryRun bool
|
||||
// Now is the clock, so tests are deterministic.
|
||||
Now time.Time
|
||||
// Duration overrides DefaultDuration.
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// Created is one event that the run wrote (or would write on a dry run).
|
||||
type Created struct {
|
||||
ItemID int64 `json:"item_id"`
|
||||
Child string `json:"child"`
|
||||
Title string `json:"title"`
|
||||
Start string `json:"start"`
|
||||
UID string `json:"uid,omitempty"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// Result is the outcome of one sync run. Failed is non-zero when a write
|
||||
// failed; the caller then reports loudly (FR-12).
|
||||
type Result struct {
|
||||
Entity string `json:"entity"`
|
||||
Candidates int `json:"candidates"`
|
||||
Created int `json:"created"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Events []Created `json:"events,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
// Sync pushes every important school event that is not in the calendar yet.
|
||||
//
|
||||
// It is idempotent in two ways: an item that already carries a Home Assistant
|
||||
// event id is not a candidate, and an event whose description carries this
|
||||
// item's marker is adopted instead of written again. Every attempt is recorded
|
||||
// in the append-only calendar_pushes table, so a failure is visible and loud
|
||||
// rather than swallowed (FR-12).
|
||||
func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *slog.Logger) (Result, error) {
|
||||
if st == nil {
|
||||
return Result{}, errors.New("calendar: store is required")
|
||||
}
|
||||
if c == nil {
|
||||
return Result{}, errors.New("calendar: client is required")
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
now := opts.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
duration := opts.Duration
|
||||
if duration <= 0 {
|
||||
duration = DefaultDuration
|
||||
}
|
||||
|
||||
res := Result{Entity: c.Entity(), DryRun: opts.DryRun}
|
||||
|
||||
candidates, err := st.CalendarCandidates(ctx, opts.ChildID, opts.ItemID, now, opts.Limit)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Candidates = len(candidates)
|
||||
if len(candidates) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// One window covers every candidate, so the marker scan is a single call.
|
||||
windowEnd := now.Add(24 * time.Hour)
|
||||
for _, it := range candidates {
|
||||
if it.DueAt != nil && it.DueAt.After(windowEnd) {
|
||||
windowEnd = it.DueAt.Add(24 * time.Hour)
|
||||
}
|
||||
}
|
||||
existing, err := c.Events(ctx, now.Add(-24*time.Hour), windowEnd)
|
||||
if err != nil {
|
||||
// A failed read is not a licence to write blind: it would risk
|
||||
// duplicates, so record one loud failure and stop.
|
||||
msg := fmt.Sprintf("calendar: reading existing events failed: %v", err)
|
||||
res.Failed = len(candidates)
|
||||
res.Errors = append(res.Errors, msg)
|
||||
for _, it := range candidates {
|
||||
if _, rerr := st.RecordCalendarPush(ctx, it.ID, c.Entity(), "", false, msg); rerr != nil {
|
||||
log.Error("record calendar push", "item", it.ID, "err", rerr)
|
||||
}
|
||||
}
|
||||
log.Error("calendar sync aborted", "entity", c.Entity(), "err", err)
|
||||
return res, nil
|
||||
}
|
||||
known := indexByMarker(existing)
|
||||
|
||||
for _, it := range candidates {
|
||||
if it.DueAt == nil {
|
||||
continue
|
||||
}
|
||||
marker := Marker(it.ID)
|
||||
|
||||
if ev, ok := known[marker]; ok {
|
||||
// Adopt an event this run (or an earlier one) already wrote.
|
||||
res.Skipped++
|
||||
uid := ev.UID
|
||||
if uid == "" {
|
||||
// No id from Home Assistant: keep the item out of the next
|
||||
// candidate set by adopting the marker as its reference.
|
||||
uid = marker
|
||||
}
|
||||
if !opts.DryRun {
|
||||
if err := st.MarkItemCalendarSynced(ctx, it.ID, uid, now); err != nil {
|
||||
log.Error("mark calendar item", "item", it.ID, "err", err)
|
||||
}
|
||||
if _, err := st.RecordCalendarPush(ctx, it.ID, c.Entity(), uid, true, ""); err != nil {
|
||||
log.Error("record calendar push", "item", it.ID, "err", err)
|
||||
}
|
||||
}
|
||||
res.Events = append(res.Events, Created{
|
||||
ItemID: it.ID, Child: it.Child, Title: it.Title,
|
||||
Start: it.DueAt.In(time.Local).Format(time.RFC3339), UID: uid,
|
||||
Summary: "already in the calendar",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
created := Created{
|
||||
ItemID: it.ID, Child: it.Child, Title: it.Title,
|
||||
Start: it.DueAt.In(time.Local).Format(time.RFC3339),
|
||||
DryRun: opts.DryRun,
|
||||
}
|
||||
if opts.DryRun {
|
||||
res.Created++
|
||||
res.Events = append(res.Events, created)
|
||||
continue
|
||||
}
|
||||
|
||||
uid, err := c.CreateEvent(ctx, Event{
|
||||
Summary: eventSummary(it),
|
||||
Description: eventDescription(it),
|
||||
Start: it.DueAt,
|
||||
End: ptrTime(it.DueAt.Add(duration)),
|
||||
})
|
||||
if err != nil {
|
||||
res.Failed++
|
||||
msg := fmt.Sprintf("item %d (%s %q): %v", it.ID, it.Child, it.Title, err)
|
||||
res.Errors = append(res.Errors, msg)
|
||||
log.Error("calendar write failed", "item", it.ID, "child", it.Child, "title", it.Title, "entity", c.Entity(), "err", err)
|
||||
if _, rerr := st.RecordCalendarPush(ctx, it.ID, c.Entity(), "", false, err.Error()); rerr != nil {
|
||||
log.Error("record calendar push", "item", it.ID, "err", rerr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if uid == "" {
|
||||
// Home Assistant did not report an event id. The marker in the
|
||||
// description still makes the next run idempotent, but say so.
|
||||
log.Warn("home assistant returned no event id; relying on the description marker",
|
||||
"item", it.ID, "child", it.Child, "title", it.Title, "entity", c.Entity())
|
||||
}
|
||||
if err := st.MarkItemCalendarSynced(ctx, it.ID, uid, now); err != nil {
|
||||
log.Error("mark calendar item", "item", it.ID, "err", err)
|
||||
}
|
||||
if _, err := st.RecordCalendarPush(ctx, it.ID, c.Entity(), uid, true, ""); err != nil {
|
||||
log.Error("record calendar push", "item", it.ID, "err", err)
|
||||
}
|
||||
res.Created++
|
||||
created.UID = uid
|
||||
res.Events = append(res.Events, created)
|
||||
}
|
||||
|
||||
sort.Slice(res.Events, func(i, j int) bool { return res.Events[i].Start < res.Events[j].Start })
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// eventSummary is the calendar entry title. The child's name is prefixed, so a
|
||||
// shared family calendar says who the event belongs to.
|
||||
func eventSummary(it store.CalendarItem) string {
|
||||
title := strings.TrimSpace(it.Title)
|
||||
if it.Child != "" {
|
||||
return it.Child + " — " + title
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// eventDescription carries the school detail, the Compass link and the
|
||||
// idempotency marker. The marker is what makes a re-run safe.
|
||||
func eventDescription(it store.CalendarItem) string {
|
||||
var b strings.Builder
|
||||
body := strings.TrimSpace(it.Body)
|
||||
if body != "" && body != strings.TrimSpace(it.Title) {
|
||||
b.WriteString(body)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if it.URL != "" {
|
||||
b.WriteString(it.URL)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if it.Subject != "" {
|
||||
b.WriteString("Subject: " + it.Subject + "\n")
|
||||
}
|
||||
b.WriteString("\n" + ItemMarker(it.ID))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// indexByMarker maps a marker to the event that carries it.
|
||||
func indexByMarker(events []Event) map[string]Event {
|
||||
out := make(map[string]Event, len(events))
|
||||
for _, ev := range events {
|
||||
if m := findMarker(ev.Description); m != "" {
|
||||
out[m] = ev
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ptrTime returns a pointer to t, so an event end can hold a computed time.
|
||||
func ptrTime(t time.Time) *time.Time { return &t }
|
||||
|
||||
// findMarker returns the first compass-nhs marker in a text, or "".
|
||||
func findMarker(text string) string {
|
||||
const open, closeTok = "[compass-nhs:", "]"
|
||||
i := strings.Index(text, open)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
start := i + len(open)
|
||||
j := strings.Index(text[start:], closeTok)
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
value := text[start : start+j]
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return open + value + closeTok
|
||||
}
|
||||
238
internal/calendar/sync_test.go
Normal file
238
internal/calendar/sync_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
|
||||
)
|
||||
|
||||
func testStore(t *testing.T) (*store.Store, int64) {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "school.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
if err := st.Migrate(context.Background()); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if err := st.SeedChildren(context.Background(), "Harry"); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
child, err := st.ChildByName(context.Background(), "Harry")
|
||||
if err != nil || child == nil {
|
||||
t.Fatalf("child: %v %v", child, err)
|
||||
}
|
||||
return st, child.ID
|
||||
}
|
||||
|
||||
// seedEvent stores one calendar event for a child.
|
||||
func seedEvent(t *testing.T, st *store.Store, childID int64, title string, due time.Time, importance string, ref string) store.Item {
|
||||
t.Helper()
|
||||
item := store.Item{
|
||||
Source: "compass",
|
||||
Type: "event",
|
||||
Title: title,
|
||||
Body: title + "\nTeacher(s): KSH",
|
||||
DueAt: &due,
|
||||
URL: "https://example.invalid/Organise/Calendar/",
|
||||
SourcePage: "calendar",
|
||||
Importance: importance,
|
||||
ExternalRef: ref,
|
||||
}
|
||||
if _, _, err := st.UpsertItems(context.Background(), childID, []store.Item{item}); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
items, err := st.ItemsForChild(context.Background(), childID)
|
||||
if err != nil {
|
||||
t.Fatalf("items: %v", err)
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.ExternalRef == ref {
|
||||
return it
|
||||
}
|
||||
}
|
||||
t.Fatalf("seeded item %q not found", ref)
|
||||
return store.Item{}
|
||||
}
|
||||
|
||||
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
func TestSyncIsIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, childID := testStore(t)
|
||||
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
|
||||
seedEvent(t, st, childID, "End of Year Senior Music Concert - HALL - KSH", due, "high", "concert")
|
||||
ha := newFakeHA(t, "calendar.school")
|
||||
c := testClient(t, ha.URL, "calendar.school")
|
||||
|
||||
first, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("first Sync: %v", err)
|
||||
}
|
||||
if first.Candidates != 1 || first.Created != 1 || first.Failed != 0 {
|
||||
t.Fatalf("first result = %+v", first)
|
||||
}
|
||||
if ha.created() != 1 {
|
||||
t.Fatalf("first Sync made %d create calls", ha.created())
|
||||
}
|
||||
|
||||
// The item now carries the Home Assistant event id, so it is no longer a
|
||||
// candidate: a re-run writes nothing.
|
||||
second, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("second Sync: %v", err)
|
||||
}
|
||||
if second.Candidates != 0 || second.Created != 0 {
|
||||
t.Fatalf("second result = %+v", second)
|
||||
}
|
||||
if ha.created() != 1 {
|
||||
t.Fatalf("re-run duplicated the event (%d create calls)", ha.created())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAdoptsAnEventByItsMarker(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, childID := testStore(t)
|
||||
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
|
||||
item := seedEvent(t, st, childID, "Year 11 Assembly - HALL - CMA", due, "high", "assembly")
|
||||
ha := newFakeHA(t, "calendar.school")
|
||||
// The event exists in Home Assistant, but the item has no id (a crashed
|
||||
// run, or a Home Assistant that reports no uid). The marker adopts it.
|
||||
ha.events = []map[string]any{{
|
||||
"uid": "uid-existing",
|
||||
"summary": "Harry — Year 11 Assembly - HALL - CMA",
|
||||
"description": "source: " + Marker(item.ID),
|
||||
"start_date_time": due.Format(time.RFC3339),
|
||||
}}
|
||||
c := testClient(t, ha.URL, "calendar.school")
|
||||
|
||||
res, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
if res.Created != 0 || res.Skipped != 1 {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
if ha.created() != 0 {
|
||||
t.Fatalf("Sync wrote a duplicate (%d create calls)", ha.created())
|
||||
}
|
||||
ci, err := st.CalendarItemByID(ctx, item.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("item: %v", err)
|
||||
}
|
||||
if ci.HAEventUID != "uid-existing" {
|
||||
t.Errorf("adopted uid = %q", ci.HAEventUID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRecordsALoudFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, childID := testStore(t)
|
||||
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
|
||||
item := seedEvent(t, st, childID, "Tour to China photo - THEATRE", due, "high", "photo")
|
||||
ha := newFakeHA(t, "calendar.school")
|
||||
ha.failCreate = true
|
||||
c := testClient(t, ha.URL, "calendar.school")
|
||||
|
||||
res, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
if res.Failed != 1 || len(res.Errors) != 1 {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
summary, err := st.CalendarPushSummary(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("summary: %v", err)
|
||||
}
|
||||
if summary.Failed != 1 || summary.Last == nil || summary.Last.OK {
|
||||
t.Fatalf("push summary = %+v", summary)
|
||||
}
|
||||
if !strings.Contains(summary.Last.Error, "calendar is busy") {
|
||||
t.Errorf("recorded error loses the reason: %q", summary.Last.Error)
|
||||
}
|
||||
// A failed write leaves the item as a candidate, so a later run retries it.
|
||||
ci, err := st.CalendarItemByID(ctx, item.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("item: %v", err)
|
||||
}
|
||||
if ci.HAEventUID != "" {
|
||||
t.Errorf("failed item should not carry an event id, got %q", ci.HAEventUID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncDryRunWritesNothing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, childID := testStore(t)
|
||||
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
|
||||
seedEvent(t, st, childID, "Senior Concert Band rehearsal - ENS", due, "high", "band")
|
||||
ha := newFakeHA(t, "calendar.school")
|
||||
c := testClient(t, ha.URL, "calendar.school")
|
||||
|
||||
res, err := Sync(ctx, st, c, Options{Now: time.Now(), DryRun: true}, silentLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
if res.Created != 1 || !res.DryRun {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
if ha.created() != 0 {
|
||||
t.Fatalf("dry run wrote %d events", ha.created())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncIgnoresRoutineLessonsAndPastEvents(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, childID := testStore(t)
|
||||
now := time.Now()
|
||||
seedEvent(t, st, childID, "5 - 11HLEGB5 - D8 - LHT", now.Add(3*time.Hour), "normal", "lesson")
|
||||
seedEvent(t, st, childID, "Year 11 Assembly - HALL", now.Add(-2*time.Hour), "high", "past")
|
||||
ha := newFakeHA(t, "calendar.school")
|
||||
c := testClient(t, ha.URL, "calendar.school")
|
||||
|
||||
candidates, err := st.CalendarCandidates(ctx, nil, nil, now, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("candidates: %v", err)
|
||||
}
|
||||
// The store selects on importance; the lesson and the past event are
|
||||
// excluded because the parser never marks a lesson high and the past event
|
||||
// is behind `now`.
|
||||
if len(candidates) != 0 {
|
||||
t.Fatalf("candidates = %+v", candidates)
|
||||
}
|
||||
res, err := Sync(ctx, st, c, Options{Now: now}, silentLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
if res.Created != 0 || ha.created() != 0 {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncReportsAReadFailureInsteadOfWritingBlind(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, childID := testStore(t)
|
||||
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
|
||||
seedEvent(t, st, childID, "Year 11 Assembly - HALL", due, "high", "assembly")
|
||||
ha := newFakeHA(t, "calendar.school")
|
||||
ha.Close() // the calendar is unreachable
|
||||
c := testClient(t, ha.URL, "calendar.school")
|
||||
|
||||
res, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
if res.Failed != 1 || res.Created != 0 {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Errors[0], "reading existing events failed") {
|
||||
t.Errorf("error = %q", res.Errors[0])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user