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 cals = new LinkedHashMap<>(); Map> evs = new HashMap<>(); public Map calendars() { Map 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 e) { evs.put(h, e); } List all() { List a = new ArrayList<>(); for (List 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, "" + c[1] + "" + c[2] + "", "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 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 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 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 = "/x/w.ics\"1\"" + unexpanded + "HTTP/1.1 200 OK"; return rr; } public void close() {} }; Cal fc = new Cal(); fc.href = "http://x/x/"; List 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 + ")"); // --- which days an event shows on in the month grid long d0m = ICal.startOfDay(now), d1m = ICal.addDays(d0m, 1), d2m = ICal.addDays(d0m, 2); check(ICal.onDay(d0m + 10 * ICal.HOUR, d0m + 11 * ICal.HOUR, d0m) && !ICal.onDay(d0m + 10 * ICal.HOUR, d0m + 11 * ICal.HOUR, d1m), "timed event on its day only"); check(ICal.onDay(d0m, d2m, d0m) && ICal.onDay(d0m, d2m, d1m) && !ICal.onDay(d0m, d2m, d2m), "2-day all-day spans exactly 2 days"); check(ICal.onDay(d0m + 22 * ICal.HOUR, d1m, d0m) && !ICal.onDay(d0m + 22 * ICal.HOUR, d1m, d1m), "ending at midnight stays on first day"); check(ICal.onDay(d0m + 23 * ICal.HOUR, d1m + 2 * ICal.HOUR, d1m), "overnight event shows on both days"); check(ICal.onDay(d1m, d1m, d1m) && !ICal.onDay(d1m, d1m, d0m), "zero-length event at midnight on its day"); java.util.TimeZone saved = java.util.TimeZone.getDefault(); java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("Europe/Madrid")); java.util.Calendar dst = java.util.Calendar.getInstance(); dst.clear(); dst.set(2026, 9, 25); // 25 h day long dstDay = dst.getTimeInMillis(), dstNext = ICal.addDays(dstDay, 1); check(dstNext - dstDay == 25 * ICal.HOUR && ICal.onDay(dstDay + 24 * ICal.HOUR, dstDay + 24 * ICal.HOUR + 1, dstDay), "DST-end day is 25 h long and handled"); java.util.TimeZone.setDefault(saved); // --- 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 l, String title) { for (Ev e : l) if (e.title.equals(title)) return e; return null; } }