test: cover the ICS writer and the calendar feed routes
This commit is contained in:
213
classes/calendar_test.go
Normal file
213
classes/calendar_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package classes_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/jakub/sm-bm-plugin/classes"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/models"
|
||||
)
|
||||
|
||||
func sp(s string) *string { return &s }
|
||||
func up(n uint) *uint { return &n }
|
||||
|
||||
func calendarFixture() classes.CalendarData {
|
||||
updated := time.Date(2026, 10, 2, 12, 30, 0, 0, time.UTC)
|
||||
glob := &models.Course{ID: 1, Name: "Globalizacja i regionalizacja", Form: models.FormWyklad, Lecturer: sp("prof. dr hab. Piotr Mikiewicz")}
|
||||
hist := &models.Course{ID: 8, Name: "Historia wojen i konfliktów do 1945 roku", Form: models.FormInne, ElectiveGroup: sp("historia-koncepcje")}
|
||||
konc := &models.Course{ID: 9, Name: "Koncepcje wojny i pokoju – historia i współczesność", Form: models.FormInne, ElectiveGroup: sp("historia-koncepcje")}
|
||||
m3 := &models.Meeting{ID: 3, Number: 3, StartsOn: "2026-10-24", EndsOn: "2026-10-25", Notes: sp("stacjonarny (Koszarowa 3, bud. 21)"), UpdatedAt: updated}
|
||||
m2 := &models.Meeting{ID: 2, Number: 2, StartsOn: "2026-10-17", EndsOn: "2026-10-18", Notes: sp("online"), UpdatedAt: updated}
|
||||
return classes.CalendarData{
|
||||
Sessions: []models.ClassSession{
|
||||
{ID: 11, CourseID: 1, Course: glob, MeetingID: up(3), Meeting: m3, StartsAt: "2026-10-24T08:00:00+02:00", EndsAt: "2026-10-24T09:30:00+02:00", Room: sp("237"), Notes: sp("prof. P. Mikiewicz"), UpdatedAt: updated},
|
||||
{ID: 12, CourseID: 8, Course: hist, MeetingID: up(3), Meeting: m3, StartsAt: "2026-10-25T08:00:00+01:00", EndsAt: "2026-10-25T09:30:00+01:00", Room: sp("110"), UpdatedAt: updated},
|
||||
{ID: 13, CourseID: 9, Course: konc, MeetingID: up(2), Meeting: m2, StartsAt: "2026-10-17T10:00:00+02:00", EndsAt: "2026-10-17T11:30:00+02:00", IsOnline: true, Room: sp("237"), UpdatedAt: updated},
|
||||
},
|
||||
Meetings: []models.Meeting{*m2, *m3},
|
||||
Deadlines: []models.Deadline{
|
||||
{ID: 21, Title: "Esej; wersja 1, szkic", DueAt: "2026-11-30T23:59:00+01:00", CourseID: up(1), Course: glob, Description: sp("Wysłać\nmailem"), UpdatedAt: updated},
|
||||
{ID: 22, Title: "Esej z koncepcji", DueAt: "2026-12-01T12:00:00+01:00", CourseID: up(9), Course: konc, UpdatedAt: updated},
|
||||
{ID: 23, Title: "Ślubowanie", DueAt: "2026-10-05T23:59:00+02:00", UpdatedAt: updated},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// unfold returns the content lines of an ICS stream.
|
||||
func unfold(t *testing.T, ics []byte) []string {
|
||||
t.Helper()
|
||||
s := string(ics)
|
||||
if !strings.HasSuffix(s, "\r\n") {
|
||||
t.Fatalf("stream does not end with CRLF")
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSuffix(s, "\r\n"), "\r\n") {
|
||||
if len(line) > 75 {
|
||||
t.Errorf("line of %d octets: %q", len(line), line)
|
||||
}
|
||||
}
|
||||
return strings.Split(strings.TrimSuffix(strings.ReplaceAll(s, "\r\n ", ""), "\r\n"), "\r\n")
|
||||
}
|
||||
|
||||
// events splits the content lines into VEVENTs keyed by UID.
|
||||
func events(lines []string) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
var cur []string
|
||||
for _, l := range lines {
|
||||
switch {
|
||||
case l == "BEGIN:VEVENT":
|
||||
cur = []string{}
|
||||
case l == "END:VEVENT":
|
||||
for _, c := range cur {
|
||||
if uid, ok := strings.CutPrefix(c, "UID:"); ok {
|
||||
out[uid] = cur
|
||||
}
|
||||
}
|
||||
cur = nil
|
||||
case cur != nil:
|
||||
cur = append(cur, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func has(lines []string, want string) bool {
|
||||
for _, l := range lines {
|
||||
if l == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestBuildCalendarHeaderAndBalance(t *testing.T) {
|
||||
lines := unfold(t, classes.BuildCalendar(calendarFixture(), classes.CalendarOptions{IncludeMeetings: true, IncludeDeadlines: true, SiteURL: "https://bm.jakubzych.com"}))
|
||||
if lines[0] != "BEGIN:VCALENDAR" || lines[len(lines)-1] != "END:VCALENDAR" {
|
||||
t.Errorf("first/last lines %q %q", lines[0], lines[len(lines)-1])
|
||||
}
|
||||
for _, want := range []string{
|
||||
"PRODID:-//jakubzych//BM UWr//PL", "VERSION:2.0", "CALSCALE:GREGORIAN", "METHOD:PUBLISH",
|
||||
"X-WR-CALNAME:BM UWr – plan zajęć", "X-WR-TIMEZONE:Europe/Warsaw",
|
||||
"REFRESH-INTERVAL;VALUE=DURATION:PT6H", "X-PUBLISHED-TTL:PT6H",
|
||||
} {
|
||||
if !has(lines, want) {
|
||||
t.Errorf("missing %q", want)
|
||||
}
|
||||
}
|
||||
depth := map[string]int{}
|
||||
for _, l := range lines {
|
||||
if c, ok := strings.CutPrefix(l, "BEGIN:"); ok {
|
||||
depth[c]++
|
||||
}
|
||||
if c, ok := strings.CutPrefix(l, "END:"); ok {
|
||||
depth[c]--
|
||||
}
|
||||
}
|
||||
for c, d := range depth {
|
||||
if d != 0 {
|
||||
t.Errorf("BEGIN/END of %s off by %d", c, d)
|
||||
}
|
||||
}
|
||||
if n := len(events(lines)); n != 3+2+3 {
|
||||
t.Errorf("%d events, want 8", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCalendarSessions(t *testing.T) {
|
||||
ev := events(unfold(t, classes.BuildCalendar(calendarFixture(), classes.CalendarOptions{SiteURL: "https://bm.jakubzych.com"})))
|
||||
glob := ev["session-11@bm.jakubzych.com"]
|
||||
for _, want := range []string{
|
||||
"DTSTART:20261024T060000Z", "DTEND:20261024T073000Z",
|
||||
"DTSTAMP:20261002T123000Z", "LAST-MODIFIED:20261002T123000Z", "SEQUENCE:0",
|
||||
"SUMMARY:Globalizacja i regionalizacja – wykład",
|
||||
`LOCATION:Koszarowa 3\, s. 237 (bud. 21 ISMiB)`,
|
||||
`DESCRIPTION:Prowadzący: prof. dr hab. Piotr Mikiewicz\nZjazd 3\nprof. P. Mikiewicz\nhttps://bm.jakubzych.com/przedmioty/1`,
|
||||
} {
|
||||
if !has(glob, want) {
|
||||
t.Errorf("session 11 lacks %q:\n%s", want, strings.Join(glob, "\n"))
|
||||
}
|
||||
}
|
||||
// After the DST change Warsaw is UTC+1.
|
||||
hist := ev["session-12@bm.jakubzych.com"]
|
||||
if !has(hist, "DTSTART:20261025T070000Z") || !has(hist, "SUMMARY:Historia wojen i konfliktów do 1945 roku") {
|
||||
t.Errorf("session 12:\n%s", strings.Join(hist, "\n"))
|
||||
}
|
||||
if !has(hist, `DESCRIPTION:Zjazd 3\nPrzedmiot do wyboru\nhttps://bm.jakubzych.com/przedmioty/8`) {
|
||||
t.Errorf("session 12 description:\n%s", strings.Join(hist, "\n"))
|
||||
}
|
||||
online := ev["session-13@bm.jakubzych.com"]
|
||||
if !has(online, "SUMMARY:Koncepcje wojny i pokoju – historia i współczesność (online)") || !has(online, "LOCATION:online") {
|
||||
t.Errorf("online session:\n%s", strings.Join(online, "\n"))
|
||||
}
|
||||
if len(ev) != 3 {
|
||||
t.Errorf("meetings or deadlines without being asked: %v", ev)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCalendarExclusions(t *testing.T) {
|
||||
ev := events(unfold(t, classes.BuildCalendar(calendarFixture(), classes.CalendarOptions{ExcludedCourseIDs: []uint{9}, IncludeDeadlines: true})))
|
||||
if _, ok := ev["session-13@bm.jakubzych.com"]; ok {
|
||||
t.Error("a session of an excluded course is listed")
|
||||
}
|
||||
if _, ok := ev["deadline-22@bm.jakubzych.com"]; ok {
|
||||
t.Error("a deadline of an excluded course is listed")
|
||||
}
|
||||
for _, uid := range []string{"session-11@bm.jakubzych.com", "session-12@bm.jakubzych.com", "deadline-21@bm.jakubzych.com", "deadline-23@bm.jakubzych.com"} {
|
||||
if _, ok := ev[uid]; !ok {
|
||||
t.Errorf("%s is missing", uid)
|
||||
}
|
||||
}
|
||||
// Without a site URL there is no link.
|
||||
if has(ev["session-11@bm.jakubzych.com"], "URL:https://bm.jakubzych.com/przedmioty/1") {
|
||||
t.Error("link without a site URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCalendarMeetingsAreAllDay(t *testing.T) {
|
||||
ev := events(unfold(t, classes.BuildCalendar(calendarFixture(), classes.CalendarOptions{IncludeMeetings: true})))
|
||||
m3 := ev["meeting-3@bm.jakubzych.com"]
|
||||
for _, want := range []string{
|
||||
"DTSTART;VALUE=DATE:20261024", "DTEND;VALUE=DATE:20261026",
|
||||
"SUMMARY:3. zjazd (stacjonarny)", "TRANSP:TRANSPARENT",
|
||||
`DESCRIPTION:stacjonarny (Koszarowa 3\, bud. 21)`,
|
||||
} {
|
||||
if !has(m3, want) {
|
||||
t.Errorf("meeting 3 lacks %q:\n%s", want, strings.Join(m3, "\n"))
|
||||
}
|
||||
}
|
||||
if !has(ev["meeting-2@bm.jakubzych.com"], "SUMMARY:2. zjazd (online)") {
|
||||
t.Errorf("meeting 2: %v", ev["meeting-2@bm.jakubzych.com"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCalendarDeadlines(t *testing.T) {
|
||||
lines := unfold(t, classes.BuildCalendar(calendarFixture(), classes.CalendarOptions{IncludeDeadlines: true}))
|
||||
d := events(lines)["deadline-21@bm.jakubzych.com"]
|
||||
for _, want := range []string{
|
||||
"DTSTART:20261130T222900Z", "DTEND:20261130T225900Z",
|
||||
`SUMMARY:Termin: Esej\; wersja 1\, szkic`,
|
||||
`DESCRIPTION:Przedmiot: Globalizacja i regionalizacja\nWysłać\nmailem`,
|
||||
"BEGIN:VALARM", "ACTION:DISPLAY", "TRIGGER:-P1D", "END:VALARM",
|
||||
} {
|
||||
if !has(d, want) {
|
||||
t.Errorf("deadline lacks %q:\n%s", want, strings.Join(d, "\n"))
|
||||
}
|
||||
}
|
||||
if strings.Contains(strings.Join(lines, "\n"), "VALARM") && has(events(lines)["session-11@bm.jakubzych.com"], "BEGIN:VALARM") {
|
||||
t.Error("a session has an alarm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaries(t *testing.T) {
|
||||
for form, want := range map[string]string{
|
||||
models.FormWyklad: "X – wykład", models.FormCwiczenia: "X – ćwiczenia", models.FormKonwersatorium: "X – konwersatorium",
|
||||
models.FormSeminarium: "X – seminarium", models.FormInne: "X",
|
||||
} {
|
||||
if got := classes.SessionSummary(models.ClassSession{Course: &models.Course{Name: "X", Form: form}}); got != want {
|
||||
t.Errorf("%s: %q, want %q", form, got, want)
|
||||
}
|
||||
}
|
||||
if got := classes.MeetingSummary(models.Meeting{Number: 7}); got != "7. zjazd" {
|
||||
t.Errorf("meeting without notes = %q", got)
|
||||
}
|
||||
}
|
||||
115
classes/ics_test.go
Normal file
115
classes/ics_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package classes_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/jakub/sm-bm-plugin/classes"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/models"
|
||||
)
|
||||
|
||||
func TestEscapeText(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"zwykły tekst": "zwykły tekst",
|
||||
`a\b`: `a\\b`,
|
||||
"a;b,c": `a\;b\,c`,
|
||||
"pierwsza\ndruga": `pierwsza\ndruga`,
|
||||
"windows\r\nkoniec\rcr": `windows\nkonieccr`,
|
||||
`już \n nie jest znakiem`: `już \\n nie jest znakiem`,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := classes.EscapeText(in); got != want {
|
||||
t.Errorf("EscapeText(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkFolded checks that a folded line has physical lines of at most 75
|
||||
// octets, each valid UTF-8, continuation lines starting with a space, and
|
||||
// that unfolding gives the input back.
|
||||
func checkFolded(t *testing.T, in, folded string) {
|
||||
t.Helper()
|
||||
parts := strings.Split(folded, "\r\n")
|
||||
for i, p := range parts {
|
||||
if len(p) > 75 {
|
||||
t.Errorf("physical line %d has %d octets: %q", i, len(p), p)
|
||||
}
|
||||
if !utf8.ValidString(p) {
|
||||
t.Errorf("physical line %d splits a UTF-8 sequence: %q", i, p)
|
||||
}
|
||||
if i > 0 && !strings.HasPrefix(p, " ") {
|
||||
t.Errorf("continuation line %d does not start with a space: %q", i, p)
|
||||
}
|
||||
}
|
||||
if unfolded := strings.ReplaceAll(folded, "\r\n ", ""); unfolded != in {
|
||||
t.Errorf("unfolded = %q, want %q", unfolded, in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldLine(t *testing.T) {
|
||||
short := "SUMMARY:" + strings.Repeat("a", 67) // exactly 75 octets
|
||||
if got := classes.FoldLine(short); got != short {
|
||||
t.Errorf("a 75-octet line was folded: %q", got)
|
||||
}
|
||||
long := "SUMMARY:" + strings.Repeat("a", 68)
|
||||
got := classes.FoldLine(long)
|
||||
if got != "SUMMARY:"+strings.Repeat("a", 67)+"\r\n a" {
|
||||
t.Errorf("76-octet line folded as %q", got)
|
||||
}
|
||||
checkFolded(t, long, got)
|
||||
|
||||
// Two-octet Polish letters at every offset, so a boundary falls inside a
|
||||
// character at some point; and a three-octet en dash.
|
||||
for pad := 0; pad < 4; pad++ {
|
||||
in := "DESCRIPTION:" + strings.Repeat("x", pad) + strings.Repeat("zażółć gęślą jaźń – ", 12)
|
||||
checkFolded(t, in, classes.FoldLine(in))
|
||||
}
|
||||
// Four-octet characters.
|
||||
emoji := "SUMMARY:" + strings.Repeat("😀", 50)
|
||||
checkFolded(t, emoji, classes.FoldLine(emoji))
|
||||
}
|
||||
|
||||
func TestICSWriterUsesCRLFAndFolds(t *testing.T) {
|
||||
var w classes.ICS
|
||||
w.Begin("VCALENDAR")
|
||||
w.Text("X-WR-CALNAME", "Bezpieczeństwo międzynarodowe; plan, zjazdy\nwiele linii "+strings.Repeat("ąę", 40))
|
||||
w.UTC("DTSTART", time.Date(2026, 10, 3, 8, 0, 0, 0, models.Location))
|
||||
w.Date("DTSTART", time.Date(2026, 10, 24, 0, 0, 0, 0, models.Location))
|
||||
w.End("VCALENDAR")
|
||||
out := string(w.Bytes())
|
||||
if !strings.HasSuffix(out, "END:VCALENDAR\r\n") {
|
||||
t.Errorf("does not end with END:VCALENDAR CRLF: %q", out)
|
||||
}
|
||||
if strings.Contains(strings.ReplaceAll(out, "\r\n", ""), "\n") || strings.Contains(strings.ReplaceAll(out, "\r\n", ""), "\r") {
|
||||
t.Errorf("bare LF or CR in %q", out)
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSuffix(out, "\r\n"), "\r\n") {
|
||||
if len(line) > 75 {
|
||||
t.Errorf("line of %d octets: %q", len(line), line)
|
||||
}
|
||||
}
|
||||
unfolded := strings.ReplaceAll(out, "\r\n ", "")
|
||||
for _, want := range []string{
|
||||
`X-WR-CALNAME:Bezpieczeństwo międzynarodowe\; plan\, zjazdy\nwiele linii ąę`,
|
||||
"DTSTART:20261003T060000Z\r\n",
|
||||
"DTSTART;VALUE=DATE:20261024\r\n",
|
||||
} {
|
||||
if !strings.Contains(unfolded, want) {
|
||||
t.Errorf("output lacks %q:\n%s", want, unfolded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatUTC(t *testing.T) {
|
||||
// Saturday before the DST change (+02:00), Sunday after it (+01:00).
|
||||
sat := time.Date(2026, 10, 24, 8, 0, 0, 0, models.Location)
|
||||
sun := time.Date(2026, 10, 25, 8, 0, 0, 0, models.Location)
|
||||
if got := classes.FormatUTC(sat); got != "20261024T060000Z" {
|
||||
t.Errorf("Saturday = %s", got)
|
||||
}
|
||||
if got := classes.FormatUTC(sun); got != "20261025T070000Z" {
|
||||
t.Errorf("Sunday = %s", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user