175 lines
12 KiB
Java
175 lines
12 KiB
Java
package org.gingercal;
|
|
|
|
import java.util.*;
|
|
|
|
import org.gingercal.Model.Cal;
|
|
import org.gingercal.Model.Ev;
|
|
|
|
/** End-to-end test of discovery, sync, create/edit/delete against a local CalDAV server. */
|
|
public class CoreTest {
|
|
static int fails = 0;
|
|
|
|
static void check(boolean ok, String what) {
|
|
System.out.println((ok ? " ok " : " FAIL ") + what);
|
|
if (!ok) fails++;
|
|
}
|
|
|
|
static class MemStore implements Model.Store {
|
|
Map<String, Cal> cals = new LinkedHashMap<>();
|
|
Map<String, List<Ev>> evs = new HashMap<>();
|
|
public Map<String, Cal> calendars() { Map<String, Cal> m = new LinkedHashMap<>(); for (Cal c : cals.values()) { Cal x = new Cal(); x.href=c.href; x.name=c.name; x.enabled=c.enabled; x.ctag=c.ctag; x.synced=c.synced; m.put(c.href, x);} return m; }
|
|
public void saveCalendar(Cal c) { cals.put(c.href, c); }
|
|
public void deleteCalendar(String h) { cals.remove(h); evs.remove(h); }
|
|
public void replaceEvents(String h, List<Ev> e) { evs.put(h, e); }
|
|
List<Ev> all() { List<Ev> a = new ArrayList<>(); for (List<Ev> l : evs.values()) a.addAll(l); a.sort(Comparator.comparingLong(e -> e.start)); return a; }
|
|
}
|
|
|
|
static String day(int offset) { return ICal.date(ICal.addDays(System.currentTimeMillis(), offset)); }
|
|
|
|
public static void main(String[] a) throws Exception {
|
|
String base = a.length > 0 ? a[0] : "http://127.0.0.1:5232/";
|
|
JdkHttp raw = new JdkHttp();
|
|
String auth = "Basic " + B64.encode("xose:secret".getBytes("UTF-8"));
|
|
String[] h = {"Authorization", auth};
|
|
// --- seed two calendars and some tricky events
|
|
for (String[] c : new String[][] {{"work", "Work stuff", "#E9322DFF"}, {"home", "Home", "#0082C9"}}) {
|
|
raw.exec("DELETE", base + "xose/" + c[0] + "/", h, null, null);
|
|
Http.Resp r = raw.exec("MKCALENDAR", base + "xose/" + c[0] + "/", h,
|
|
"<?xml version=\"1.0\"?><C:mkcalendar xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:caldav\" xmlns:A=\"http://apple.com/ns/ical/\"><D:set><D:prop><D:displayname>" + c[1] + "</D:displayname><A:calendar-color>" + c[2] + "</A:calendar-color></D:prop></D:set></C:mkcalendar>",
|
|
"application/xml");
|
|
check(r.code == 201, "MKCALENDAR " + c[0] + " -> " + r.code);
|
|
}
|
|
String tz = "BEGIN:VTIMEZONE\r\nTZID:Europe/Madrid\r\nBEGIN:STANDARD\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nEND:DAYLIGHT\r\nEND:VTIMEZONE\r\n";
|
|
String timed = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:test\r\n" + tz + "BEGIN:VEVENT\r\nUID:timed-1\r\nDTSTAMP:20260101T000000Z\r\nDTSTART;TZID=Europe/Madrid:" + day(1) + "T100000\r\nDTEND;TZID=Europe/Madrid:" + day(1) + "T113000\r\nSUMMARY:Reunión\\, equipo ñ\r\nLOCATION:Vigo\\; sala 2\r\nDESCRIPTION:Line one\\nLine two with a very long text that definitely needs folding because it is over seventy-five octets long\r\nATTENDEE;CN=\"Doe, Jane\":mailto:jane@example.com\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:keep me\r\nTRIGGER:-PT30M\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
|
String allday = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:test\r\nBEGIN:VEVENT\r\nUID:allday-1\r\nDTSTAMP:20260101T000000Z\r\nDTSTART;VALUE=DATE:" + day(2) + "\r\nDTEND;VALUE=DATE:" + day(4) + "\r\nSUMMARY:Holidays\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
|
String weekly = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:test\r\n" + tz + "BEGIN:VEVENT\r\nUID:weekly-1\r\nDTSTAMP:20260101T000000Z\r\nDTSTART;TZID=Europe/Madrid:" + day(-14) + "T090000\r\nDTEND;TZID=Europe/Madrid:" + day(-14) + "T093000\r\nRRULE:FREQ=WEEKLY;COUNT=10\r\nEXDATE;TZID=Europe/Madrid:" + day(7) + "T090000\r\nSUMMARY:Standup\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:weekly-1\r\nDTSTAMP:20260101T000000Z\r\nRECURRENCE-ID;TZID=Europe/Madrid:" + day(14) + "T090000\r\nDTSTART;TZID=Europe/Madrid:" + day(14) + "T120000\r\nDTEND;TZID=Europe/Madrid:" + day(14) + "T123000\r\nSUMMARY:Standup (moved)\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
|
check(raw.exec("PUT", base + "xose/work/timed-1.ics", h, timed, "text/calendar").code == 201, "seed timed");
|
|
check(raw.exec("PUT", base + "xose/home/allday-1.ics", h, allday, "text/calendar").code == 201, "seed all-day");
|
|
check(raw.exec("PUT", base + "xose/work/weekly-1.ics", h, weekly, "text/calendar").code == 201, "seed weekly");
|
|
|
|
// --- discovery
|
|
JdkHttp http = new JdkHttp();
|
|
Dav dav = new Dav(http, base.replace("http://", "http://"), "xose", "secret");
|
|
String home = dav.calendarHome();
|
|
System.out.println(" home = " + home);
|
|
check(home.endsWith("/xose/"), "calendar home discovered via fallbacks");
|
|
List<Cal> cals = dav.calendars(home);
|
|
check(cals.size() == 2, "two calendars listed (" + cals.size() + ")");
|
|
for (Cal c : cals) System.out.printf(" cal %s name=%s color=%08X writable=%s ctag=%s%n", c.href, c.name, c.color, c.writable, c.ctag);
|
|
Cal work = null;
|
|
for (Cal c : cals) if (c.name.equals("Work stuff")) work = c;
|
|
check(work != null && work.color == 0xFFE9322D, "displayname + colour parsed");
|
|
|
|
// --- bad password
|
|
try {
|
|
new Dav(new JdkHttp(), base, "xose", "wrong").calendarHome();
|
|
check(false, "401 detected");
|
|
} catch (DavException e) {
|
|
check(e.code == 401, "401 detected: " + e.getMessage().substring(0, 30));
|
|
}
|
|
|
|
// --- sync
|
|
MemStore st = new MemStore();
|
|
long now = System.currentTimeMillis();
|
|
Syncer.Result r = Syncer.sync(dav, st, now, 7, 30, false);
|
|
System.out.println(" sync: " + r.summary());
|
|
List<Ev> all = st.all();
|
|
for (Ev e : all) System.out.printf(" %-26s %s -> %s allDay=%s rec=%s%n", e.title, new Date(e.start), new Date(e.end), e.allDay, e.recurring);
|
|
Ev t1 = find(all, "Reunión, equipo ñ");
|
|
check(t1 != null, "escaped summary decoded");
|
|
Calendar exp = Calendar.getInstance(TimeZone.getTimeZone("Europe/Madrid"));
|
|
exp.setTimeInMillis(ICal.addDays(ICal.startOfDay(now), 1));
|
|
exp.set(Calendar.HOUR_OF_DAY, 10);
|
|
check(t1 != null && t1.start == exp.getTimeInMillis() && t1.end - t1.start == 90 * 60000, "TZID time correct");
|
|
check(t1 != null && t1.location.equals("Vigo; sala 2") && t1.descr.startsWith("Line one\nLine two"), "location/description unescaped");
|
|
Ev ad = find(all, "Holidays");
|
|
check(ad != null && ad.allDay && ad.end == ICal.addDays(ad.start, 2), "all-day 2 days");
|
|
int standups = 0, moved = 0;
|
|
for (Ev e : all) { if (e.title.equals("Standup")) standups++; if (e.title.equals("Standup (moved)")) moved++; }
|
|
// window: -7d..+30d; weekly from -14d count 10 -> days -14,-7,0,7(ex),14(moved),21,28,35..: in window -7,0,21,28 + moved
|
|
System.out.println(" standups=" + standups + " moved=" + moved);
|
|
check(standups == 4 && moved == 1, "recurrence expanded with EXDATE and override");
|
|
for (Ev e : all) if (e.title.startsWith("Standup")) check(e.recurring, "instance flagged recurring");
|
|
|
|
// --- ctag skip
|
|
int before = http.requests;
|
|
Syncer.Result r2 = Syncer.sync(dav, st, now, 7, 30, false);
|
|
check(r2.skipped == 2 && r2.fetched == 0, "second sync skips unchanged calendars (" + r2.summary() + ", " + (http.requests - before) + " requests)");
|
|
|
|
// --- create
|
|
String uid = UUID.randomUUID().toString();
|
|
long s = ICal.addDays(ICal.startOfDay(now), 3) + 15 * ICal.HOUR;
|
|
dav.create(work.href, uid, ICal.newEvent(uid, "Dentist, 2nd floor", "Rúa do Príncipe", "Bring card\nand ID", s, s + ICal.HOUR, false, 15));
|
|
String[] got = dav.get(work.href + uid + ".ics");
|
|
ICal.VEvent ve = ICal.parse(got[0]).get(0);
|
|
check(ve.summary.equals("Dentist, 2nd floor") && ve.start == s && ve.alarmMin == 15 && ve.description.equals("Bring card\nand ID"), "created event round-trips");
|
|
try { dav.create(work.href, uid, ICal.newEvent(uid, "dup", "", "", s, s, false, -1)); check(false, "If-None-Match prevents overwrite"); }
|
|
catch (DavException e) { check(e.code == 412, "If-None-Match prevents overwrite"); }
|
|
|
|
// --- edit the timed event (keeps attendee + its alarm)
|
|
String[] t = dav.get(work.href + "timed-1.ics");
|
|
check(!ICal.isRecurring(t[0]), "timed is not recurring");
|
|
check(ICal.isRecurring(dav.get(work.href + "weekly-1.ics")[0]), "weekly is recurring");
|
|
String mod = ICal.modify(t[0], "Reunión movida", "", "nueva", t1.start + ICal.HOUR, t1.end + ICal.HOUR, false, -2);
|
|
dav.update(work.href + "timed-1.ics", t[1], mod);
|
|
String[] t2 = dav.get(work.href + "timed-1.ics");
|
|
ICal.VEvent m = ICal.parse(t2[0]).get(0);
|
|
check(m.summary.equals("Reunión movida") && m.start == t1.start + ICal.HOUR && m.location.equals("") && m.sequence == 1, "edit applied");
|
|
check(t2[0].contains("ATTENDEE") && m.alarmMin == 30 && t2[0].contains("keep me"), "unrelated props + alarm kept");
|
|
try { dav.update(work.href + "timed-1.ics", t[1], mod); check(false, "stale etag rejected"); }
|
|
catch (DavException e) { check(e.code == 412, "stale etag rejected"); }
|
|
String mod2 = ICal.modify(t2[0], "Reunión movida", "", "nueva", t1.start, t1.end, false, 10);
|
|
dav.update(work.href + "timed-1.ics", t2[1], mod2);
|
|
ICal.VEvent m2 = ICal.parse(dav.get(work.href + "timed-1.ics")[0]).get(0);
|
|
check(m2.alarmMin == 10 && m2.sequence == 2, "alarm replaced");
|
|
|
|
// --- all-day create
|
|
String uid2 = UUID.randomUUID().toString();
|
|
long d0 = ICal.addDays(ICal.startOfDay(now), 5);
|
|
dav.create(cals.get(0).href, uid2, ICal.newEvent(uid2, "Trip", "", "", d0, ICal.addDays(d0, 1), true, -1));
|
|
|
|
// --- delete
|
|
dav.delete(work.href + uid + ".ics", null);
|
|
try { dav.get(work.href + uid + ".ics"); check(false, "deleted"); } catch (DavException e) { check(e.code == 404, "deleted"); }
|
|
|
|
Syncer.Result r3 = Syncer.sync(dav, st, now, 7, 30, false);
|
|
check(find(st.all(), "Trip") != null && find(st.all(), "Trip").allDay, "new all-day event synced (" + r3.summary() + ")");
|
|
check(find(st.all(), "Dentist, 2nd floor") == null, "deleted event gone after sync");
|
|
|
|
// --- disabling a calendar clears its events
|
|
st.cals.get(work.href).enabled = false;
|
|
Syncer.sync(dav, st, now, 7, 30, false);
|
|
check(find(st.all(), "Standup") == null, "disabled calendar emptied");
|
|
|
|
// --- client-side expansion when a server ignores <C:expand>
|
|
final String unexpanded = weekly.replace("&", "&").replace("<", "<");
|
|
Http fake = new Http() {
|
|
public Resp exec(String m, String u, String[] hh, String b, String ct) {
|
|
Resp rr = new Resp(); rr.code = 207;
|
|
rr.body = "<d:multistatus xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:response><d:href>/x/w.ics</d:href><d:propstat><d:prop><d:getetag>\"1\"</d:getetag><c:calendar-data>" + unexpanded + "</c:calendar-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>";
|
|
return rr;
|
|
}
|
|
public void close() {}
|
|
};
|
|
Cal fc = new Cal(); fc.href = "http://x/x/";
|
|
List<Ev> fe = new Dav(fake, "http://x/", "u", "p").events(fc, ICal.addDays(ICal.startOfDay(now), -7), ICal.addDays(ICal.startOfDay(now), 31));
|
|
int fs = 0, fm = 0;
|
|
for (Ev e : fe) { if (e.title.equals("Standup")) fs++; if (e.title.equals("Standup (moved)")) fm++; }
|
|
check(fs == 4 && fm == 1, "client-side RRULE/EXDATE/override expansion (" + fs + "+" + fm + ")");
|
|
|
|
// --- folding
|
|
String longLine = ICal.fold("DESCRIPTION:" + "ñ".repeat(60));
|
|
boolean okFold = true;
|
|
for (String l : longLine.split("\r\n")) okFold &= l.getBytes("UTF-8").length <= 75;
|
|
check(okFold && ICal.unfold(longLine).get(0).equals("DESCRIPTION:" + "ñ".repeat(60)), "UTF-8 safe folding");
|
|
|
|
System.out.println(fails == 0 ? "ALL PASSED" : fails + " FAILED");
|
|
System.exit(fails == 0 ? 0 : 1);
|
|
}
|
|
|
|
static Ev find(List<Ev> l, String title) {
|
|
for (Ev e : l) if (e.title.equals(title)) return e;
|
|
return null;
|
|
}
|
|
}
|