Unlock commands now support a given time, e.g 10 minutes.
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
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 {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Type == "usermessage" && messages[i].Kind == "info" {
|
||||
message, _ := messages[i].Value.(string)
|
||||
return message
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if got := latestHallInfo(drainMessages(participant)); !strings.Contains(got, "30 minutes") {
|
||||
t.Fatalf("schedule announcement = %q", got)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
if got := latestHallInfo(drainMessages(operator)); got != "Hall unlocked" {
|
||||
t.Fatalf("unlock announcement = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
if got := latestHallInfo(drainMessages(operator)); got != "Hall unlocked" {
|
||||
t.Fatalf("immediate unlock announcement = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
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 !foundCancellation {
|
||||
t.Fatalf("lock messages missing scheduled-unlock cancellation: %#v", messages)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user