Unlock commands now support a given time, e.g 10 minutes.

This commit is contained in:
Storm Dragon
2026-07-22 21:37:34 -04:00
parent 704b1a9558
commit 6a94f893b6
8 changed files with 934 additions and 34 deletions
+434
View File
@@ -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)
}
}