package rtpconn import ( "strings" "testing" "time" "git.stormux.org/storm/skald/hall" ) func unlockRequest(spec, timeZone string, timezoneOffset float64) map[string]interface{} { return map[string]interface{}{ "spec": spec, "timeZone": timeZone, "timezoneOffset": timezoneOffset, } } func TestParseScheduledUnlock(t *testing.T) { location, err := time.LoadLocation("America/New_York") if err != nil { t.Fatalf("LoadLocation: %v", err) } now := time.Date(2026, time.July, 21, 15, 0, 0, 0, location) tests := []struct { name string spec string wantHour int wantMin int wantErr string }{ {name: "lowercase duration", spec: "30m", wantHour: 15, wantMin: 30}, {name: "uppercase duration", spec: "30M", wantHour: 15, wantMin: 30}, {name: "24 hour", spec: "15:45", wantHour: 15, wantMin: 45}, {name: "one digit hour is not 24 hour", spec: "3:45", wantErr: "Invalid unlock time"}, {name: "12 hour spaced", spec: "3:45 PM", wantHour: 15, wantMin: 45}, {name: "12 hour compact lowercase", spec: "3:45pm", wantHour: 15, wantMin: 45}, {name: "midnight", spec: "12:30 AM", wantErr: "14 hours in the past"}, {name: "unqualified noon is 24 hour", spec: "12:00", wantErr: "3 hours in the past"}, {name: "missing meridiem minutes", spec: "3 PM", wantErr: "Invalid unlock time"}, {name: "invalid hour", spec: "25:00", wantErr: "Invalid unlock time"}, {name: "zero duration", spec: "0m", wantErr: "at least 1 minute"}, {name: "duration cannot contain space", spec: "30 m", wantErr: "Invalid unlock time"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { got, err := parseScheduledUnlock( unlockRequest(test.spec, "America/New_York", 240), now, ) if test.wantErr != "" { if err == nil || !strings.Contains(err.Error(), test.wantErr) { t.Fatalf("parseScheduledUnlock(%q) error = %v, want containing %q", test.spec, err, test.wantErr) } return } if err != nil { t.Fatalf("parseScheduledUnlock(%q): %v", test.spec, err) } local := got.In(location) if local.Hour() != test.wantHour || local.Minute() != test.wantMin { t.Fatalf("parseScheduledUnlock(%q) = %v, want %02d:%02d", test.spec, local, test.wantHour, test.wantMin) } if !sameDate(local, now) { t.Fatalf("parseScheduledUnlock(%q) changed date: %v", test.spec, local) } }) } } func TestParseScheduledUnlockRequiresNamedTimezoneForClockTime(t *testing.T) { now := time.Date(2026, time.July, 21, 15, 0, 0, 0, time.UTC) _, err := parseScheduledUnlock(unlockRequest("16:00", "", 0), now) if err == nil || !strings.Contains(err.Error(), "browser timezone is unavailable") { t.Fatalf("clock time without named timezone error = %v", err) } target, err := parseScheduledUnlock(unlockRequest("30m", "", 0), now) if err != nil { t.Fatalf("duration with offset fallback: %v", err) } if got := target.Sub(now); got != 30*time.Minute { t.Fatalf("duration with offset fallback = %v, want 30m", got) } } func TestParseScheduledUnlockRejectsCrossMidnight(t *testing.T) { location, err := time.LoadLocation("America/New_York") if err != nil { t.Fatalf("LoadLocation: %v", err) } now := time.Date(2026, time.July, 21, 23, 45, 0, 0, location) _, err = parseScheduledUnlock( unlockRequest("30m", "America/New_York", 240), now, ) if err == nil || !strings.Contains(err.Error(), "crosses midnight") { t.Fatalf("cross-midnight duration error = %v", err) } } func TestParseScheduledUnlockUsesDaylightSavingTime(t *testing.T) { location, err := time.LoadLocation("America/New_York") if err != nil { t.Fatalf("LoadLocation: %v", err) } now := time.Date(2026, time.March, 8, 1, 30, 0, 0, location) target, err := parseScheduledUnlock( unlockRequest("03:30", "America/New_York", 300), now, ) if err != nil { t.Fatalf("parseScheduledUnlock across DST boundary: %v", err) } if got := target.Sub(now); got != time.Hour { t.Fatalf("DST-aware duration = %v, want 1h", got) } } func TestParseScheduledUnlockRejectsNonexistentDaylightSavingTime(t *testing.T) { location, err := time.LoadLocation("America/New_York") if err != nil { t.Fatalf("LoadLocation: %v", err) } now := time.Date(2026, time.March, 8, 1, 30, 0, 0, location) _, err = parseScheduledUnlock( unlockRequest("02:30", "America/New_York", 300), now, ) if err == nil || !strings.Contains(err.Error(), "does not exist today") { t.Fatalf("nonexistent DST time error = %v", err) } } func TestParseScheduledUnlockChoosesNextRepeatedDaylightSavingTime(t *testing.T) { location, err := time.LoadLocation("America/New_York") if err != nil { t.Fatalf("LoadLocation: %v", err) } tests := []struct { name string nowUTC time.Time wantUTC time.Time }{ { name: "before first occurrence", nowUTC: time.Date(2026, time.November, 1, 4, 30, 0, 0, time.UTC), wantUTC: time.Date(2026, time.November, 1, 5, 30, 0, 0, time.UTC), }, { name: "during second occurrence", nowUTC: time.Date(2026, time.November, 1, 6, 15, 0, 0, time.UTC), wantUTC: time.Date(2026, time.November, 1, 6, 30, 0, 0, time.UTC), }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { target, err := parseScheduledUnlock( unlockRequest("01:30", "America/New_York", 240), test.nowUTC, ) if err != nil { t.Fatalf("parse repeated clock time: %v", err) } if !target.Equal(test.wantUTC) { t.Fatalf("repeated clock target = %v (%v), want %v", target, target.In(location), test.wantUTC) } }) } } func TestUnlockWarningTimes(t *testing.T) { tests := []struct { duration time.Duration want []time.Duration }{ {30 * time.Minute, []time.Duration{20 * time.Minute, 10 * time.Minute, 5 * time.Minute}}, {29 * time.Minute, []time.Duration{10 * time.Minute, 5 * time.Minute}}, {15 * time.Minute, []time.Duration{10 * time.Minute, 5 * time.Minute}}, {6 * time.Minute, []time.Duration{5 * time.Minute}}, {5 * time.Minute, nil}, } for _, test := range tests { got := unlockWarningTimes(test.duration) if len(got) != len(test.want) { t.Fatalf("unlockWarningTimes(%v) = %v, want %v", test.duration, got, test.want) } for i := range got { if got[i] != test.want[i] { t.Fatalf("unlockWarningTimes(%v) = %v, want %v", test.duration, got, test.want) } } } } func latestHallInfo(messages []clientMessage) string { message, ok := latestHallInfoMessage(messages) if !ok { return "" } text, _ := message.Value.(string) return text } func latestHallInfoMessage(messages []clientMessage) (clientMessage, bool) { for i := len(messages) - 1; i >= 0; i-- { if messages[i].Type == "usermessage" && messages[i].Kind == "info" { return messages[i], true } } return clientMessage{}, false } func scheduledUnlockEntry(g *hall.Hall) *scheduledHallUnlock { hallUnlockSchedules.Lock() defer hallUnlockSchedules.Unlock() return hallUnlockSchedules.entries[g] } func TestScheduledUnlockRequiresIssuingOperator(t *testing.T) { hallName := newChalkboardTestHall(t) operator := addTestWebClient(t, hallName, "Operator", "op") participant := addTestWebClient(t, hallName, "Participant", "present") g := operator.hall g.SetLocked(true, "") t.Cleanup(func() { cancelScheduledHallUnlock(g) }) now := time.Now() if err := scheduleHallUnlock(g, operator, now.Add(30*time.Minute), now); err != nil { t.Fatalf("scheduleHallUnlock: %v", err) } entry := scheduledUnlockEntry(g) if entry == nil { t.Fatal("scheduled unlock was not stored") } if got := len(entry.timers); got != 4 { t.Fatalf("30-minute schedule has %d timers, want 4", got) } scheduleMessage, ok := latestHallInfoMessage(drainMessages(participant)) if !ok { t.Fatal("schedule announcement was not sent") } if got, _ := scheduleMessage.Value.(string); !strings.Contains(got, "30 minutes") { t.Fatalf("schedule announcement = %q", got) } if !scheduleMessage.Notify { t.Fatal("schedule announcement did not request a browser notification") } announceScheduledUnlock(entry, 20*time.Minute) warningMessage, ok := latestHallInfoMessage(drainMessages(participant)) if !ok { t.Fatal("countdown announcement was not sent") } if got, _ := warningMessage.Value.(string); got != "Hall will unlock in 20 minutes." { t.Fatalf("countdown announcement = %q", got) } if !warningMessage.Notify { t.Fatal("countdown announcement did not request a browser notification") } hall.DelClient(operator) drainMessages(participant) finishScheduledUnlock(entry) if locked, _ := g.Locked(); !locked { t.Fatal("hall unlocked after issuing operator left") } if got := latestHallInfo(drainMessages(participant)); !strings.Contains(got, "Operator is no longer present") { t.Fatalf("departure cancellation announcement = %q", got) } } func TestScheduledUnlockCompletesWhileIssuerIsOperator(t *testing.T) { hallName := newChalkboardTestHall(t) operator := addTestWebClient(t, hallName, "Operator", "op") g := operator.hall g.SetLocked(true, "") t.Cleanup(func() { cancelScheduledHallUnlock(g) }) now := time.Now() if err := scheduleHallUnlock(g, operator, now.Add(6*time.Minute), now); err != nil { t.Fatalf("scheduleHallUnlock: %v", err) } entry := scheduledUnlockEntry(g) drainMessages(operator) finishScheduledUnlock(entry) if locked, _ := g.Locked(); locked { t.Fatal("hall remained locked while issuing operator was present") } unlockMessage, ok := latestHallInfoMessage(drainMessages(operator)) if !ok { t.Fatal("unlock announcement was not sent") } if got, _ := unlockMessage.Value.(string); got != "Hall unlocked" { t.Fatalf("unlock announcement = %q", got) } if !unlockMessage.Notify { t.Fatal("scheduled unlock completion did not request a browser notification") } } func TestScheduledUnlockTimerCompletesWhileIssuerIsOperator(t *testing.T) { g, hallName := newWaitingTestHall(t, 0) operator := addTestWebClient(t, hallName, "Operator", "op") g.SetLocked(true, "") waiter := joinWaitingTestClient(t, hallName, "Alice") if err := updateWaitingPreference(waiter, true); err != nil { t.Fatalf("enable automatic waiting-room admission: %v", err) } now := time.Now() if err := scheduleHallUnlock(g, operator, now.Add(20*time.Millisecond), now); err != nil { t.Fatalf("scheduleHallUnlock: %v", err) } deadline := time.Now().Add(time.Second) for { locked, _ := g.Locked() if !locked && waitingClientHall(waiter) == nil { break } if time.Now().After(deadline) { t.Fatal("scheduled timer left hall locked while issuing operator was present") } time.Sleep(time.Millisecond) } drainActions(t, waiter) if waiter.hall != g || waiter.waiting != nil { t.Fatalf("waiting user was not admitted after scheduled unlock: hall=%v waiting=%v", waiter.hall, waiter.waiting) } } func TestScheduledUnlockRequiresOperatorPermissionAtDeadline(t *testing.T) { hallName := newChalkboardTestHall(t) operator := addTestWebClient(t, hallName, "Operator", "op") participant := addTestWebClient(t, hallName, "Participant", "present") g := operator.hall g.SetLocked(true, "") t.Cleanup(func() { cancelScheduledHallUnlock(g) }) now := time.Now() if err := scheduleHallUnlock(g, operator, now.Add(15*time.Minute), now); err != nil { t.Fatalf("scheduleHallUnlock: %v", err) } entry := scheduledUnlockEntry(g) drainMessages(participant) operator.SetPermissions([]string{"present"}) finishScheduledUnlock(entry) if locked, _ := g.Locked(); !locked { t.Fatal("hall unlocked after issuer lost operator permission") } if got := latestHallInfo(drainMessages(participant)); !strings.Contains(got, "Operator is no longer an operator") { t.Fatalf("permission cancellation announcement = %q", got) } } func TestScheduledUnlockRequestReplacesPreviousSchedule(t *testing.T) { hallName := newChalkboardTestHall(t) operator := addTestWebClient(t, hallName, "Operator", "op") g := operator.hall g.SetLocked(true, "") t.Cleanup(func() { cancelScheduledHallUnlock(g) }) location, err := time.LoadLocation("America/New_York") if err != nil { t.Fatalf("LoadLocation: %v", err) } now := time.Date(2026, time.July, 21, 15, 0, 0, 0, location) firstDeadline, err := parseScheduledUnlock( unlockRequest("30m", "America/New_York", 240), now, ) if err != nil { t.Fatalf("parse first schedule: %v", err) } if err := scheduleHallUnlock(g, operator, firstDeadline, now); err != nil { t.Fatalf("schedule first unlock: %v", err) } first := scheduledUnlockEntry(g) drainMessages(operator) secondDeadline, err := parseScheduledUnlock( unlockRequest("20m", "America/New_York", 240), now, ) if err != nil { t.Fatalf("parse second schedule: %v", err) } if err := scheduleHallUnlock(g, operator, secondDeadline, now); err != nil { t.Fatalf("schedule second unlock: %v", err) } second := scheduledUnlockEntry(g) if second == nil || second == first { t.Fatal("second request did not replace the first schedule") } if got := latestHallInfo(drainMessages(operator)); !strings.Contains(got, "Previous scheduled unlock replaced") { t.Fatalf("replacement announcement = %q", got) } } func TestTimedUnlockHallActionCreatesSchedule(t *testing.T) { hallName := newChalkboardTestHall(t) operator := addTestWebClient(t, hallName, "Operator", "op") g := operator.hall g.SetLocked(true, "") t.Cleanup(func() { cancelScheduledHallUnlock(g) }) oldUnlockNow := unlockNow location, err := time.LoadLocation("America/New_York") if err != nil { t.Fatalf("LoadLocation: %v", err) } unlockNow = func() time.Time { return time.Date(2026, time.July, 21, 15, 0, 0, 0, location) } t.Cleanup(func() { unlockNow = oldUnlockNow }) if err := handleClientMessage(operator, clientMessage{ Type: "hallaction", Kind: "unlock", Value: unlockRequest("30m", "America/New_York", 240), }); err != nil { t.Fatalf("timed unlock hall action: %v", err) } entry := scheduledUnlockEntry(g) if entry == nil { t.Fatal("timed unlock hall action did not create a schedule") } if got := latestHallInfo(drainMessages(operator)); !strings.Contains(got, "30 minutes") { t.Fatalf("timed unlock announcement = %q", got) } } func TestImmediateUnlockCommandCancelsScheduledUnlock(t *testing.T) { hallName := newChalkboardTestHall(t) operator := addTestWebClient(t, hallName, "Operator", "op") g := operator.hall g.SetLocked(true, "") t.Cleanup(func() { cancelScheduledHallUnlock(g) }) now := time.Now() if err := scheduleHallUnlock(g, operator, now.Add(30*time.Minute), now); err != nil { t.Fatalf("scheduleHallUnlock: %v", err) } entry := scheduledUnlockEntry(g) drainMessages(operator) if err := handleClientMessage(operator, clientMessage{ Type: "hallaction", Kind: "unlock", }); err != nil { t.Fatalf("immediate unlock hall action: %v", err) } if scheduledUnlockEntry(g) != nil { t.Fatal("immediate unlock left scheduled unlock active") } finishScheduledUnlock(entry) if locked, _ := g.Locked(); locked { t.Fatal("immediate unlock left hall locked") } unlockMessage, ok := latestHallInfoMessage(drainMessages(operator)) if !ok { t.Fatal("immediate unlock announcement was not sent") } if got, _ := unlockMessage.Value.(string); got != "Hall unlocked" { t.Fatalf("immediate unlock announcement = %q", got) } if unlockMessage.Notify { t.Fatal("immediate unlock requested a scheduled-unlock browser notification") } } func TestLockCommandCancelsScheduledUnlock(t *testing.T) { hallName := newChalkboardTestHall(t) operator := addTestWebClient(t, hallName, "Operator", "op") g := operator.hall g.SetLocked(true, "") t.Cleanup(func() { cancelScheduledHallUnlock(g) }) now := time.Now() if err := scheduleHallUnlock(g, operator, now.Add(30*time.Minute), now); err != nil { t.Fatalf("scheduleHallUnlock: %v", err) } entry := scheduledUnlockEntry(g) drainMessages(operator) if err := handleClientMessage(operator, clientMessage{ Type: "hallaction", Kind: "lock", Value: "Remain closed", }); err != nil { t.Fatalf("lock hall action: %v", err) } if scheduledUnlockEntry(g) != nil { t.Fatal("lock command left scheduled unlock active") } if locked, message := g.Locked(); !locked || message != "Remain closed" { t.Fatalf("lock state = %v, %q", locked, message) } finishScheduledUnlock(entry) if locked, message := g.Locked(); !locked || message != "Remain closed" { t.Fatalf("cancelled deadline changed lock state to %v, %q", locked, message) } messages := drainMessages(operator) foundCancellation := false for _, message := range messages { if message.Kind == "info" && strings.Contains(message.Value.(string), "locked again") { foundCancellation = true if !message.Notify { t.Fatal("scheduled-unlock cancellation did not request a browser notification") } } } if !foundCancellation { t.Fatalf("lock messages missing scheduled-unlock cancellation: %#v", messages) } }