oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
4 "bytes"
5 "testing"
6 "time"
7)
8
9func TestSplitSeriesKeepsOverridesWithOriginalOccurrence(t *testing.T) {
10 a := NewForTest(t, t.TempDir())
11 ctx := t.Context()
12 p, err := a.store.EnsurePersonSetup(ctx, "split@example.test")
13 if err != nil {
14 t.Fatal(err)
15 }
16 cal, err := a.store.DefaultCalendarID(ctx, p.ID)
17 if err != nil {
18 t.Fatal(err)
19 }
20 input := EventInput{CalendarID: cal, Title: "Daily", StartLocal: "2026-09-01T09:00", EndLocal: "2026-09-01T10:00", TZ: "Europe/Dublin", RRule: "FREQ=DAILY;COUNT=30", Kind: KindMeeting, Status: StatusConfirmed}
21 master, err := a.store.CreateEvent(ctx, p.ID, input)
22 if err != nil {
23 t.Fatal(err)
24 }
25 cases := []struct {
26 original, start, end string
27 tail, cancel bool
28 }{
29 {"2026-09-03T09:00", "2026-09-20T11:00", "2026-09-20T12:00", false, false},
30 {"2026-09-10T09:00", "2026-09-10T11:00", "2026-09-10T12:00", true, false},
31 {"2026-09-12T09:00", "2026-09-02T11:00", "2026-09-02T12:00", true, false},
32 {"2026-09-14T09:00", "2026-09-04T11:00", "2026-09-04T12:00", true, true},
33 }
34 originals := map[string]Event{}
35 for _, c := range cases {
36 moved := input
37 moved.RRule = ""
38 moved.StartLocal = c.start
39 moved.EndLocal = c.end
40 if err := a.store.UpdateOccurrence(ctx, p.ID, master.ID, c.original, moved); err != nil {
41 t.Fatal(err)
42 }
43 ov, ok, err := a.store.findOverride(ctx, master.ID, c.original)
44 if err != nil || !ok {
45 t.Fatal("missing override", err)
46 }
47 if c.cancel {
48 if err := a.store.DeleteEvent(ctx, p.ID, ov.ID); err != nil {
49 t.Fatal(err)
50 }
51 if err := a.db.G.First(&ov, ov.ID).Error; err != nil {
52 t.Fatal(err)
53 }
54 }
55 originals[c.original] = ov
56 }
57 tailInput := input
58 tailInput.Title = "Following"
59 tailInput.StartLocal = "2026-09-10T09:00"
60 tailInput.EndLocal = "2026-09-10T10:00"
61 if err := a.store.SplitSeries(ctx, p.ID+100, master.ID, "2026-09-10T09:00", tailInput); err == nil {
62 t.Fatal("foreign split accepted")
63 }
64 if err := a.store.SplitSeries(ctx, p.ID, master.ID, "2026-09-10T09:00", tailInput); err != nil {
65 t.Fatal(err)
66 }
67 var tail Event
68 if err := a.db.G.Where("calendar_id = ? AND id <> ? AND series_id IS NULL", cal, master.ID).First(&tail).Error; err != nil {
69 t.Fatal(err)
70 }
71 if tail.UIDHash == master.UIDHash || bytes.Equal(tail.UIDSealed, master.UIDSealed) {
72 t.Fatal("tail reused master identity")
73 }
74 for _, c := range cases {
75 var got Event
76 old := originals[c.original]
77 if err := a.db.G.First(&got, old.ID).Error; err != nil {
78 t.Fatal(err)
79 }
80 want := master
81 if c.tail {
82 want = tail
83 }
84 if got.SeriesID == nil || *got.SeriesID != want.ID || got.UIDHash != want.UIDHash || !bytes.Equal(got.UIDSealed, want.UIDSealed) {
85 t.Errorf("%s: exception belongs to wrong series or UID", c.original)
86 }
87 if got.RecurrenceIDLocal != c.original || got.StartLocal != c.start || got.EndLocal != c.end || (got.DeletedAt != nil) != c.cancel {
88 t.Errorf("%s: changed occurrence or cancellation", c.original)
89 }
90 if c.tail && (got.Sequence <= old.Sequence || got.UpdatedAt.Before(old.UpdatedAt)) {
91 t.Errorf("%s: export revision did not advance", c.original)
92 }
93 if !c.tail && (got.Sequence != old.Sequence || !got.UpdatedAt.Equal(old.UpdatedAt)) {
94 t.Errorf("%s: head exception changed", c.original)
95 }
96 }
97 from := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC).Unix()
98 rows, err := a.store.EventsForFeed(ctx, p.ID, cal, from, from+40*86400)
99 if err != nil {
100 t.Fatal(err)
101 }
102 uids := map[int64]string{}
103 for _, row := range rows {
104 if row.SeriesID == nil {
105 uids[row.ID] = row.UIDSealedOpen
106 }
107 }
108 if len(uids) != 2 {
109 t.Fatalf("feed masters = %d", len(uids))
110 }
111 overrides := 0
112 for _, row := range rows {
113 if row.SeriesID != nil {
114 overrides++
115 if row.UIDSealedOpen == "" || row.UIDSealedOpen != uids[*row.SeriesID] {
116 t.Errorf("exported override %d has wrong UID", row.ID)
117 }
118 if row.ID == originals["2026-09-14T09:00"].ID && row.Status != StatusCancelled {
119 t.Fatal("cancelled exception resurrected in feed")
120 }
121 }
122 }
123 if overrides != len(cases) {
124 t.Fatalf("exported exceptions = %d", overrides)
125 }
126}
127
128func TestSplitSeriesOriginalBoundaryForAllDayAndDST(t *testing.T) {
129 for _, tc := range []struct {
130 name, zone, start, end, original, moved, movedEnd, cut, cutEnd string
131 allDay bool
132 }{
133 {"all-day", "", "2026-10-01", "2026-10-02", "2026-10-26", "2026-10-02", "2026-10-03", "2026-10-25", "2026-10-26", true},
134 {"dst-fold", "Europe/Dublin", "2026-10-01T01:30", "2026-10-01T02:30", "2026-10-25T01:30", "2026-10-02T11:00", "2026-10-02T12:00", "2026-10-25T01:30", "2026-10-25T02:30", false},
135 } {
136 t.Run(tc.name, func(t *testing.T) {
137 a := NewForTest(t, t.TempDir())
138 ctx := t.Context()
139 p, err := a.store.EnsurePersonSetup(ctx, "boundary@example.test")
140 if err != nil {
141 t.Fatal(err)
142 }
143 cal, err := a.store.DefaultCalendarID(ctx, p.ID)
144 if err != nil {
145 t.Fatal(err)
146 }
147 input := EventInput{CalendarID: cal, Title: "Recurring", StartLocal: tc.start, EndLocal: tc.end, TZ: tc.zone, AllDay: tc.allDay, RRule: "FREQ=DAILY;COUNT=40", Kind: KindMeeting, Status: StatusConfirmed}
148 master, err := a.store.CreateEvent(ctx, p.ID, input)
149 if err != nil {
150 t.Fatal(err)
151 }
152 moved := input
153 moved.RRule = ""
154 moved.StartLocal = tc.moved
155 moved.EndLocal = tc.movedEnd
156 if err := a.store.UpdateOccurrence(ctx, p.ID, master.ID, tc.original, moved); err != nil {
157 t.Fatal(err)
158 }
159 old, ok, err := a.store.findOverride(ctx, master.ID, tc.original)
160 if err != nil || !ok {
161 t.Fatal("missing override", err)
162 }
163 input.StartLocal = tc.cut
164 input.EndLocal = tc.cutEnd
165 if err := a.store.SplitSeries(ctx, p.ID, master.ID, tc.cut, input); err != nil {
166 t.Fatal(err)
167 }
168 var got Event
169 if err := a.db.G.First(&got, old.ID).Error; err != nil {
170 t.Fatal(err)
171 }
172 if got.SeriesID == nil || *got.SeriesID == master.ID || got.UIDHash == master.UIDHash || got.RecurrenceIDLocal != tc.original || got.StartUTC != old.StartUTC {
173 t.Fatal("original occurrence lost its tail or moved instant")
174 }
175 })
176 }
177}
178
179func TestSplitSeriesRollsBackIfOverrideCannotBeClassified(t *testing.T) {
180 a := NewForTest(t, t.TempDir())
181 ctx := t.Context()
182 p, err := a.store.EnsurePersonSetup(ctx, "rollback@example.test")
183 if err != nil {
184 t.Fatal(err)
185 }
186 cal, err := a.store.DefaultCalendarID(ctx, p.ID)
187 if err != nil {
188 t.Fatal(err)
189 }
190 in := EventInput{CalendarID: cal, Title: "Recurring", StartLocal: "2026-09-01T09:00", EndLocal: "2026-09-01T10:00", TZ: "UTC", RRule: "FREQ=DAILY;COUNT=30", Kind: KindMeeting, Status: StatusConfirmed}
191 master, err := a.store.CreateEvent(ctx, p.ID, in)
192 if err != nil {
193 t.Fatal(err)
194 }
195 moved := in
196 moved.RRule = ""
197 moved.StartLocal = "2026-09-12T11:00"
198 moved.EndLocal = "2026-09-12T12:00"
199 if err := a.store.UpdateOccurrence(ctx, p.ID, master.ID, "2026-09-12T09:00", moved); err != nil {
200 t.Fatal(err)
201 }
202 ov, ok, err := a.store.findOverride(ctx, master.ID, "2026-09-12T09:00")
203 if err != nil || !ok {
204 t.Fatal("missing override", err)
205 }
206 if err := a.db.G.Model(&Event{}).Where("id = ?", ov.ID).Update("recurrence_id_local", "invalid-legacy-value").Error; err != nil {
207 t.Fatal(err)
208 }
209 in.StartLocal = "2026-09-10T09:00"
210 in.EndLocal = "2026-09-10T10:00"
211 if err := a.store.SplitSeries(ctx, p.ID, master.ID, "2026-09-10T09:00", in); err == nil {
212 t.Fatal("unclassifiable exception silently reassigned")
213 }
214 current, err := a.store.Event(ctx, p.ID, master.ID)
215 if err != nil {
216 t.Fatal(err)
217 }
218 if current.RRule != master.RRule || current.Sequence != master.Sequence {
219 t.Fatal("head changed despite rollback")
220 }
221 var count int64
222 if err := a.db.G.Model(&Event{}).Where("calendar_id = ?", cal).Count(&count).Error; err != nil {
223 t.Fatal(err)
224 }
225 if count != 2 {
226 t.Fatal("split left a partial tail")
227 }
228}
229