| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "net/http/httptest" |
| 7 | "testing" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | func TestNativeQuickPreviewAndSave(t *testing.T) { |
| 12 | f := newHomeRouterFixture(t) |
| 13 | call := func(path string, value any) *httptest.ResponseRecorder { |
| 14 | body, _ := json.Marshal(value) |
| 15 | r := httptest.NewRequest("POST", homeTestOrigin+path, bytes.NewReader(body)) |
| 16 | r.Header.Set("Origin", homeTestOrigin) |
| 17 | r.Header.Set("Content-Type", "application/json") |
| 18 | for _, c := range f.cookies { |
| 19 | r.AddCookie(c) |
| 20 | } |
| 21 | w := httptest.NewRecorder() |
| 22 | f.app.mux.ServeHTTP(w, r) |
| 23 | return w |
| 24 | } |
| 25 | input := map[string]string{"text": "Lunch tomorrow at 1pm for an hour with bob@example.test", "zone": "Pacific/Kiritimati"} |
| 26 | if call("/api/native/quick/parse", input).Code == 200 { |
| 27 | t.Fatal("anonymous preview") |
| 28 | } |
| 29 | if call("/api/native/quick", nativeQuickDraft{}).Code == 200 { |
| 30 | t.Fatal("anonymous save") |
| 31 | } |
| 32 | f.local(t) |
| 33 | f.cookies = nativeClientCookies(t, f.app.mux, f.cookies) |
| 34 | var preview nativeQuickPreview |
| 35 | w := call("/api/native/quick/parse", input) |
| 36 | if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &preview) != nil { |
| 37 | t.Fatal("preview failed", w.Code) |
| 38 | } |
| 39 | loc, _ := Zone(input["zone"]) |
| 40 | expected := ParseQuick(input["text"], time.Now().In(loc), input["zone"]) |
| 41 | if preview.Draft.StartLocal != expected.StartLocal || preview.Draft.TZ != input["zone"] || preview.Draft.Title != expected.Title || len(preview.Draft.Attendees) != 1 { |
| 42 | t.Fatal("preview diverged from existing parser or requested zone") |
| 43 | } |
| 44 | p, err := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test") |
| 45 | if err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | personal, _ := f.app.store.DefaultCalendarID(t.Context(), p.ID) |
| 49 | if preview.Draft.CalendarID != personal || preview.CalendarName == "" || preview.When == "" { |
| 50 | t.Fatal("preview missing personal calendar or civil time") |
| 51 | } |
| 52 | // Save concrete dates from the preview, not a later interpretation of tomorrow. |
| 53 | preview.Draft.StartLocal = "2026-10-25T09:00" |
| 54 | preview.Draft.EndLocal = "2026-10-25T10:00" |
| 55 | preview.Draft.Repeat = RepeatWeekly |
| 56 | w = call("/api/native/quick", preview.Draft) |
| 57 | var saved struct{ ID int64 } |
| 58 | if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &saved) != nil || saved.ID == 0 { |
| 59 | t.Fatal("save failed", w.Code) |
| 60 | } |
| 61 | ev, err := f.app.store.Event(t.Context(), p.ID, saved.ID) |
| 62 | if err != nil || ev.StartLocal != preview.Draft.StartLocal || ev.TZ != preview.Draft.TZ || ev.RRule != "FREQ=WEEKLY" { |
| 63 | t.Fatal("saved meaning changed") |
| 64 | } |
| 65 | attendees, err := f.app.store.AttendeesFor(t.Context(), saved.ID) |
| 66 | if err != nil || len(attendees) != 1 || attendees[0] != "bob@example.test" { |
| 67 | t.Fatal("attendees not recorded") |
| 68 | } |
| 69 | preview.Draft.CalendarID = 999999 |
| 70 | if call("/api/native/quick", preview.Draft).Code == 200 { |
| 71 | t.Fatal("unauthorized calendar accepted") |
| 72 | } |
| 73 | preview.Draft.CalendarID = personal |
| 74 | preview.Draft.Repeat = "unsupported" |
| 75 | if call("/api/native/quick", preview.Draft).Code != 422 { |
| 76 | t.Fatal("unsupported repeat silently lost") |
| 77 | } |
| 78 | input["zone"] = "invalid/not-a-zone" |
| 79 | if call("/api/native/quick/parse", input).Code != 422 { |
| 80 | t.Fatal("invalid zone accepted") |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | func TestNativeViewingZoneUsesRequestedCivilDay(t *testing.T) { |
| 85 | f := newHomeRouterFixture(t) |
| 86 | f.local(t) |
| 87 | p, _ := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test") |
| 88 | cal, _ := f.app.store.DefaultCalendarID(t.Context(), p.ID) |
| 89 | _, err := f.app.store.CreateEvent(t.Context(), p.ID, EventInput{CalendarID: cal, Title: "Across midnight", StartLocal: "2026-10-24T12:00", EndLocal: "2026-10-24T13:00", TZ: "UTC", Kind: KindMeeting, Status: StatusConfirmed}) |
| 90 | if err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | for _, tc := range []struct { |
| 94 | zone string |
| 95 | count int |
| 96 | }{{"Pacific/Kiritimati", 1}, {"America/Los_Angeles", 0}} { |
| 97 | w := f.do("GET", "/api/native/events?from=2026-10-25&to=2026-10-26&zone="+tc.zone, "") |
| 98 | var response struct{ Events []nativeEvent } |
| 99 | if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &response) != nil || len(response.Events) != tc.count { |
| 100 | t.Fatalf("civil query wrong for %s", tc.zone) |
| 101 | } |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | func TestNativeQuickConfirmsSavedEventWithReminderWarning(t *testing.T) { |
| 106 | f := newHomeRouterFixture(t) |
| 107 | f.local(t) |
| 108 | p, _ := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test") |
| 109 | cal, _ := f.app.store.DefaultCalendarID(t.Context(), p.ID) |
| 110 | if _, err := f.app.db.Writer().Exec("CREATE TRIGGER native_quick_reminder_failure BEFORE INSERT ON reminders BEGIN SELECT RAISE(FAIL, 'fixture'); END"); err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | draft := nativeQuickDraft{CalendarID: cal, Title: "Confirmed quick event", StartLocal: "2026-10-25T09:00", EndLocal: "2026-10-25T10:00", TZ: "Europe/Dublin"} |
| 114 | body, _ := json.Marshal(draft) |
| 115 | r := httptest.NewRequest("POST", homeTestOrigin+"/api/native/quick", bytes.NewReader(body)) |
| 116 | r.Header.Set("Origin", homeTestOrigin) |
| 117 | for _, c := range f.cookies { |
| 118 | r.AddCookie(c) |
| 119 | } |
| 120 | w := httptest.NewRecorder() |
| 121 | f.app.mux.ServeHTTP(w, r) |
| 122 | var result struct { |
| 123 | ID int64 |
| 124 | Warning string |
| 125 | } |
| 126 | if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || result.ID == 0 || result.Warning == "" { |
| 127 | t.Fatal("saved quick event reported as failure", w.Code) |
| 128 | } |
| 129 | event, err := f.app.store.Event(t.Context(), p.ID, result.ID) |
| 130 | if err != nil || event.Title != draft.Title { |
| 131 | t.Fatal("confirmed event not persisted") |
| 132 | } |
| 133 | } |
| 134 | |