509 lines
16 KiB
Go
509 lines
16 KiB
Go
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)
|
|
}
|
|
}
|