├── .github └── funding.yml ├── .gitignore ├── globalentry.png ├── go.mod ├── .goreleaser.yaml ├── Makefile ├── LICENSE ├── .circleci └── config.yml ├── go.sum ├── cmd ├── main.go └── main_test.go └── README.md /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: arun0009 2 | buy_me_a_coffee: arun0009 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.iml 3 | bin/ 4 | vendor/ 5 | global-entry-slot-notifier -------------------------------------------------------------------------------- /globalentry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arun0009/global-entry-slot-notifier/HEAD/globalentry.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/arun0009/global-entry-slot-notifier 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 7 | github.com/spf13/cobra v1.9.1 8 | ) 9 | 10 | require ( 11 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect 12 | github.com/godbus/dbus/v5 v5.1.0 // indirect 13 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 14 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect 15 | github.com/spf13/pflag v1.0.6 // indirect 16 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect 17 | golang.org/x/sys v0.6.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | builds: 4 | - id: global-entry-slot-notifier 5 | main: ./cmd/main.go 6 | binary: global-entry-slot-notifier 7 | env: 8 | - CGO_ENABLED=0 9 | goos: 10 | - linux 11 | - darwin 12 | - windows 13 | goarch: 14 | - amd64 15 | - arm64 16 | ldflags: 17 | - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} 18 | flags: 19 | - -trimpath 20 | 21 | archives: 22 | - id: default 23 | builds: 24 | - global-entry-slot-notifier 25 | format: tar.gz 26 | name_template: "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 27 | files: 28 | - LICENSE 29 | - README.md 30 | 31 | release: 32 | github: 33 | owner: arun0009 34 | name: global-entry-slot-notifier 35 | draft: false 36 | prerelease: auto 37 | name_template: "{{ .Tag }}" 38 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Define variables 2 | BINARY_NAME=global-entry-slot-notifier 3 | SRC_DIR=./cmd/main.go 4 | WITH_FLAGS= 5 | 6 | # Default target 7 | .PHONY: all 8 | all: deps build 9 | 10 | # Build the binary 11 | .PHONY: build 12 | build: 13 | @echo "Building $(BINARY_NAME)..." 14 | @go build -o $(BINARY_NAME) $(SRC_DIR) 15 | 16 | # Run tests 17 | .PHONY: test 18 | test: 19 | @echo "Running tests..." 20 | @go test -v ./... 21 | 22 | # Clean build files 23 | .PHONY: clean 24 | clean: 25 | @echo "Cleaning..." 26 | @rm -f $(BINARY_NAME) 27 | 28 | 29 | # Install dependencies 30 | .PHONY: deps 31 | deps: 32 | @echo "Installing dependencies..." 33 | @go mod tidy 34 | 35 | # Display help 36 | .PHONY: help 37 | help: 38 | @echo "Makefile targets:" 39 | @echo " build - Build the binary" 40 | @echo " test - Run tests" 41 | @echo " clean - Clean build files" 42 | @echo " deps - Install dependencies" 43 | @echo " help - Display this help message" 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Arun Gopalpuri 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation 4 | files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, 5 | modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the 6 | Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 11 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 12 | BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF 13 | OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2.1 3 | 4 | orbs: 5 | goreleaser: hubci/goreleaser@2.5.0 6 | 7 | executors: 8 | golang: 9 | docker: 10 | - image: cimg/go:1.23 11 | 12 | jobs: 13 | build: 14 | executor: golang 15 | steps: 16 | - checkout 17 | - run: go mod download 18 | - run: go build -o global-entry-slot-notifier cmd/main.go 19 | 20 | workflows: 21 | version: 2 22 | global-entry-slot-notifier: 23 | jobs: 24 | - build: 25 | filters: 26 | tags: 27 | only: /.*/ 28 | - goreleaser/release: 29 | name: test-release 30 | version: '2.1.0' 31 | go-version: '1.20' 32 | dry-run: true 33 | requires: 34 | - build 35 | filters: 36 | tags: 37 | only: /.*/ 38 | - goreleaser/release: 39 | name: release 40 | version: '2.1.0' 41 | go-version: '1.20' 42 | requires: 43 | - build 44 | filters: 45 | tags: 46 | only: /^v.*/ 47 | branches: 48 | ignore: /.*/ -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 2 | github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 h1:ygs9POGDQpQGLJPlq4+0LBUmMBNox1N4JSpw+OETcvI= 3 | github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4/go.mod h1:0W7dI87PvXJ1Sjs0QPvWXKcQmNERY77e8l7GFhZB/s4= 4 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE= 5 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10= 6 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 7 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 8 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 9 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 10 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= 11 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= 12 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 13 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 14 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 15 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 16 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 17 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= 18 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= 19 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 20 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 21 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 22 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 23 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "os" 11 | "strings" 12 | "time" 13 | 14 | "github.com/gen2brain/beeep" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | const hhmmLayout = "15:04" // Go’s layout for HH:MM (24-hour) 19 | 20 | // Set to 10 to search through multiple time slots. 21 | const GlobalEntryUrl = "https://ttp.cbp.dhs.gov/schedulerapi/slots?orderBy=soonest&limit=10&locationId=%s&minimum=1" 22 | 23 | // Appointment represents the structure of the appointment JSON returned by the Global Entry API response. 24 | type Appointment struct { 25 | LocationID int `json:"locationId"` 26 | StartTimestamp string `json:"startTimestamp"` 27 | EndTimestamp string `json:"endTimestamp"` 28 | Active bool `json:"active"` 29 | Duration int `json:"duration"` 30 | RemoteInt bool `json:"remoteInd"` 31 | } 32 | 33 | // HTTPClient is an interface for making HTTP requests. 34 | type HTTPClient interface { 35 | Get(url string) (resp *http.Response, err error) 36 | Post(url, contentType string, body io.Reader) (resp *http.Response, err error) 37 | } 38 | 39 | // Notifier is an interface for sending notifications. 40 | type Notifier interface { 41 | Notify(locationID int, startTime string, topic string) error 42 | } 43 | 44 | // CheckOptions holds optional time-of-day filters in minutes after midnight; -1 means unset. 45 | type CheckOptions struct { 46 | EarliestMinutes int 47 | LatestMinutes int 48 | } 49 | 50 | // AppNotifier sends notifications via an app. 51 | type AppNotifier struct { 52 | Client HTTPClient 53 | } 54 | 55 | func (a AppNotifier) Notify(locationID int, startTime string, topic string) error { 56 | _, err := a.Client.Post(fmt.Sprintf("https://ntfy.sh/%s", topic), "text/plain", 57 | strings.NewReader(fmt.Sprintf("Global Entry appointment available at %d on %s", locationID, startTime))) 58 | return err 59 | } 60 | 61 | // SystemNotifier sends system notifications. 62 | type SystemNotifier struct{} 63 | 64 | func (s SystemNotifier) Notify(locationID int, startTime string, topic string) error { 65 | return beeep.Notify("Appointment Slot Available", fmt.Sprintf("Appointment at %d on %s", locationID, startTime), "assets/information.png") 66 | } 67 | 68 | // parseHHMM parses "HH:MM" in 24-hour format and returns minutes since midnight. 69 | // If input is invalid, returns -1. 70 | func parseHHMM(s string) int { 71 | if s == "" { 72 | return -1 73 | } 74 | t, err := time.Parse(hhmmLayout, s) 75 | if err != nil { 76 | return -1 77 | } 78 | return t.Hour()*60 + t.Minute() 79 | } 80 | 81 | // appointmentCheckScheduler calls the provided appointmentCheck function at regular intervals. 82 | func appointmentCheckScheduler(interval time.Duration, appointmentCheck func()) { 83 | ticker := time.NewTicker(interval) 84 | defer ticker.Stop() 85 | for { 86 | select { 87 | case <-ticker.C: 88 | appointmentCheck() 89 | } 90 | } 91 | } 92 | 93 | // appointmentCheck retrieves the appointment slots and triggers the appropriate notifier. 94 | func appointmentCheck(url string, client HTTPClient, notifier Notifier, topic string, beforeDate time.Time, opts CheckOptions) { 95 | response, err := client.Get(url) 96 | if err != nil { 97 | log.Printf("Failed to get appointment slots: %v", err) 98 | return 99 | } 100 | defer response.Body.Close() 101 | 102 | responseData, err := io.ReadAll(response.Body) 103 | if err != nil { 104 | log.Printf("Failed to read response body: %v", err) 105 | return 106 | } 107 | 108 | var appointments []Appointment 109 | err = json.Unmarshal(responseData, &appointments) 110 | if err != nil { 111 | log.Printf("Failed to unmarshal response data: %v", err) 112 | return 113 | } 114 | 115 | found := false 116 | 117 | for _, appointment := range appointments { 118 | appointmentTime, err := time.Parse("2006-01-02T15:04", appointment.StartTimestamp) 119 | if err != nil { 120 | log.Printf("Failed to parse appointment time: %v", err) 121 | continue 122 | } 123 | 124 | // Filter out appointments after cutoff date 125 | if appointmentTime.After(beforeDate) { 126 | continue 127 | } 128 | 129 | // Optional time-of-day window 130 | if opts.EarliestMinutes >= 0 || opts.LatestMinutes >= 0 { 131 | minOfDay := appointmentTime.Hour()*60 + appointmentTime.Minute() 132 | if opts.EarliestMinutes >= 0 && minOfDay < opts.EarliestMinutes { 133 | continue 134 | } 135 | if opts.LatestMinutes >= 0 && minOfDay > opts.LatestMinutes { 136 | continue 137 | } 138 | } 139 | 140 | // Valid appointment 141 | if err := notifier.Notify(appointment.LocationID, appointment.StartTimestamp, topic); err != nil { 142 | log.Printf("Failed to send notification for %s: %v", appointment.StartTimestamp, err) 143 | continue 144 | } 145 | found = true 146 | break // notify once per cycle (first valid, API is orderBy=soonest) 147 | } 148 | 149 | if !found { 150 | log.Printf("[%s] No valid appointments found", time.Now().Format("2006-01-02 15:04:05")) 151 | } 152 | } 153 | 154 | func main() { 155 | var location, notifierType, topic, before string 156 | var earliestStr, latestStr string 157 | var interval time.Duration 158 | 159 | rootCmd := &cobra.Command{ 160 | Use: "global-entry-slot-notifier", 161 | Short: "Checks for appointment slots and sends notifications", 162 | Run: func(cmd *cobra.Command, args []string) { 163 | // Function to prompt user for input 164 | getInput := func(prompt string) string { 165 | reader := bufio.NewReader(os.Stdin) 166 | fmt.Print(prompt) 167 | input, _ := reader.ReadString('\n') 168 | return strings.TrimSpace(input) 169 | } 170 | 171 | // Check and prompt for missing flags 172 | if location == "" { 173 | location = getInput("Enter the location ID: ") 174 | } 175 | if notifierType == "" { 176 | notifierType = getInput("Enter the notifier type (app/system): ") 177 | } 178 | if notifierType == "app" && topic == "" { 179 | topic = getInput("Enter the ntfy topic: ") 180 | } 181 | 182 | // Validate flags 183 | if location == "" || notifierType == "" || (notifierType == "app" && topic == "") { 184 | fmt.Println("Both --location and --notifier flags are required. If notifier is app, --topic is required.") 185 | _ = cmd.Usage() 186 | os.Exit(1) 187 | } 188 | 189 | url := fmt.Sprintf(GlobalEntryUrl, location) 190 | 191 | var notifier Notifier 192 | client := &http.Client{} 193 | switch notifierType { 194 | case "app": 195 | notifier = AppNotifier{Client: client} 196 | case "system": 197 | notifier = SystemNotifier{} 198 | default: 199 | log.Fatalf("Unknown notifier type: %s", notifierType) 200 | } 201 | 202 | beforeDate := time.Now().AddDate(1, 0, 0) // Default: 1 year from now 203 | if before != "" { 204 | parsedBefore, err := time.Parse("2006-01-02", before) 205 | if err == nil { 206 | beforeDate = parsedBefore 207 | } else { 208 | log.Printf("Invalid before date format, using default (1 year from now)") 209 | } 210 | } 211 | 212 | // Build options from optional earliest/latest (HH:MM, 24-hour). If invalid, ignore. 213 | earliestMin := parseHHMM(earliestStr) 214 | latestMin := parseHHMM(latestStr) 215 | 216 | if earliestStr != "" && earliestMin < 0 { 217 | log.Printf("Invalid --earliest (expected HH:MM), ignoring") 218 | earliestMin = -1 219 | } 220 | if latestStr != "" && latestMin < 0 { 221 | log.Printf("Invalid --latest (expected HH:MM), ignoring") 222 | latestMin = -1 223 | } 224 | if earliestMin >= 0 && latestMin >= 0 && latestMin < earliestMin { 225 | log.Printf("--latest is before --earliest; ignoring time window") 226 | earliestMin, latestMin = -1, -1 227 | } 228 | 229 | opts := CheckOptions{ 230 | EarliestMinutes: earliestMin, 231 | LatestMinutes: latestMin, 232 | } 233 | 234 | // Create a closure that captures the arguments and calls appointmentCheck with them. 235 | appointmentCheckFunc := func() { 236 | appointmentCheck(url, client, notifier, topic, beforeDate, opts) 237 | } 238 | 239 | go appointmentCheckScheduler(interval, appointmentCheckFunc) 240 | 241 | // Keep the main function running to allow the ticker to tick. 242 | select {} 243 | }, 244 | } 245 | 246 | rootCmd.Flags().StringVarP(&location, "location", "l", "", "Specify the location ID") 247 | rootCmd.Flags().StringVarP(¬ifierType, "notifier", "n", "", "Specify the notifier type (app or system)") 248 | rootCmd.Flags().StringVarP(&topic, "topic", "t", "", "Specify the ntfy topic (required if notifier is app)") 249 | rootCmd.Flags().DurationVarP(&interval, "interval", "i", time.Second*60, "Specify the interval") 250 | rootCmd.Flags().StringVarP(&before, "before", "b", "", "Show only appointments before the specified date (YYYY-MM-DD)") 251 | rootCmd.Flags().StringVarP(&earliestStr, "earliest", "e", "", "Only appointments at/after this time (HH:MM, 24-hour)") 252 | rootCmd.Flags().StringVarP(&latestStr, "latest", "L", "", "Only appointments at/before this time (HH:MM, 24-hour)") 253 | 254 | if err := rootCmd.Execute(); err != nil { 255 | fmt.Println(err) 256 | os.Exit(1) 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /cmd/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | "net/http" 8 | "strings" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | // MockHTTPClient is a mock implementation of the HTTPClient interface for testing. 14 | type MockHTTPClient struct { 15 | Response *http.Response 16 | Err error 17 | } 18 | 19 | func (m *MockHTTPClient) Get(url string) (*http.Response, error) { 20 | return m.Response, m.Err 21 | } 22 | 23 | func (m *MockHTTPClient) Post(url, contentType string, body io.Reader) (*http.Response, error) { 24 | return m.Response, m.Err 25 | } 26 | 27 | // MockNotifier is a mock implementation of the Notifier interface for testing. 28 | type MockNotifier struct { 29 | Called bool 30 | LocationID int 31 | StartTime string 32 | Topic string 33 | ReturnError bool 34 | } 35 | 36 | func (m *MockNotifier) Notify(locationID int, startTime string, topic string) error { 37 | m.Called = true 38 | m.LocationID = locationID 39 | m.StartTime = startTime 40 | m.Topic = topic 41 | if m.ReturnError { 42 | return errors.New("mock error") 43 | } 44 | return nil 45 | } 46 | 47 | // ---- Helpers for constructing timestamps used by tests ---- 48 | 49 | const apiTimeLayout = "2006-01-02T15:04" 50 | 51 | // makeTS returns an API-formatted timestamp at (now + dayOffset) with H:M. 52 | func makeTS(dayOffset int, hour, min int) string { 53 | base := time.Now().AddDate(0, 0, dayOffset) 54 | t := time.Date(base.Year(), base.Month(), base.Day(), hour, min, 0, 0, base.Location()) 55 | return t.Format(apiTimeLayout) 56 | } 57 | 58 | // makeJSON is a small helper to marshal appointments. 59 | func makeJSON(appts []Appointment) string { 60 | b, _ := json.Marshal(appts) 61 | return string(b) 62 | } 63 | 64 | // defaultOpts returns CheckOptions with no time-of-day filtering. 65 | func defaultOpts() CheckOptions { 66 | return CheckOptions{EarliestMinutes: -1, LatestMinutes: -1} 67 | } 68 | 69 | // ----------------------------------------------------------- 70 | 71 | func TestAppointmentCheck(t *testing.T) { 72 | futureTime := makeTS(1, 10, 30) // 24h+ from now, API layout "2006-01-02T15:04" 73 | appointments := []Appointment{ 74 | {LocationID: 123, StartTimestamp: futureTime}, 75 | } 76 | appointmentsJSON := makeJSON(appointments) 77 | 78 | mockClient := &MockHTTPClient{ 79 | Response: &http.Response{ 80 | StatusCode: http.StatusOK, 81 | Body: io.NopCloser(strings.NewReader(appointmentsJSON)), 82 | }, 83 | Err: nil, 84 | } 85 | mockNotifier := &MockNotifier{} 86 | 87 | url := "http://example.com" 88 | topic := "test-topic" 89 | beforeDate := time.Now().Add(48 * time.Hour) // Allow appointments within 48 hours 90 | 91 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, defaultOpts()) 92 | 93 | if !mockNotifier.Called { 94 | t.Fatalf("Expected Notify to be called") 95 | } 96 | if mockNotifier.LocationID != 123 { 97 | t.Errorf("Expected LocationID to be %d, got %d", 123, mockNotifier.LocationID) 98 | } 99 | if mockNotifier.Topic != topic { 100 | t.Errorf("Expected topic to be %s, got %s", topic, mockNotifier.Topic) 101 | } 102 | } 103 | 104 | func TestAppointmentCheck_NoAppointments(t *testing.T) { 105 | appointmentsJSON := makeJSON([]Appointment{}) 106 | 107 | mockClient := &MockHTTPClient{ 108 | Response: &http.Response{ 109 | StatusCode: http.StatusOK, 110 | Body: io.NopCloser(strings.NewReader(appointmentsJSON)), 111 | }, 112 | Err: nil, 113 | } 114 | mockNotifier := &MockNotifier{} 115 | 116 | url := "http://example.com" 117 | topic := "test-topic" 118 | beforeDate := time.Now().Add(48 * time.Hour) // Allow appointments within 48 hours 119 | 120 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, defaultOpts()) 121 | 122 | if mockNotifier.Called { 123 | t.Fatalf("Expected Notify not to be called") 124 | } 125 | } 126 | 127 | func TestAppointmentCheck_AppointmentOutsideBeforeDate(t *testing.T) { 128 | // 3 days from now (outside 48h window), using the API layout so it parses. 129 | outsideTime := makeTS(3, 9, 0) 130 | appointments := []Appointment{ 131 | {LocationID: 123, StartTimestamp: outsideTime}, 132 | } 133 | appointmentsJSON := makeJSON(appointments) 134 | 135 | mockClient := &MockHTTPClient{ 136 | Response: &http.Response{ 137 | StatusCode: http.StatusOK, 138 | Body: io.NopCloser(strings.NewReader(appointmentsJSON)), 139 | }, 140 | Err: nil, 141 | } 142 | mockNotifier := &MockNotifier{} 143 | 144 | url := "http://example.com" 145 | topic := "test-topic" 146 | beforeDate := time.Now().Add(48 * time.Hour) // Only allow appointments within 2 days 147 | 148 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, defaultOpts()) 149 | 150 | if mockNotifier.Called { 151 | t.Fatalf("Expected Notify not to be called for appointments after beforeDate") 152 | } 153 | } 154 | 155 | func TestAppointmentCheck_HTTPError(t *testing.T) { 156 | mockClient := &MockHTTPClient{ 157 | Response: nil, 158 | Err: errors.New("http error"), 159 | } 160 | mockNotifier := &MockNotifier{} 161 | 162 | url := "http://example.com" 163 | topic := "test-topic" 164 | beforeDate := time.Now().Add(48 * time.Hour) // Allow appointments within 48 hours 165 | 166 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, defaultOpts()) 167 | 168 | if mockNotifier.Called { 169 | t.Fatalf("Expected Notify not to be called") 170 | } 171 | } 172 | 173 | func TestAppointmentCheck_UnmarshalError(t *testing.T) { 174 | mockClient := &MockHTTPClient{ 175 | Response: &http.Response{ 176 | StatusCode: http.StatusOK, 177 | Body: io.NopCloser(strings.NewReader("invalid json")), 178 | }, 179 | Err: nil, 180 | } 181 | mockNotifier := &MockNotifier{} 182 | 183 | url := "http://example.com" 184 | topic := "test-topic" 185 | beforeDate := time.Now().Add(48 * time.Hour) // Allow appointments within 48 hours 186 | 187 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, defaultOpts()) 188 | 189 | if mockNotifier.Called { 190 | t.Fatalf("Expected Notify not to be called") 191 | } 192 | } 193 | 194 | func TestAppointmentCheckScheduler(t *testing.T) { 195 | count := 0 196 | appointmentCheckFn := func() { 197 | count++ 198 | } 199 | 200 | go appointmentCheckScheduler(1*time.Second, appointmentCheckFn) 201 | 202 | time.Sleep(3500 * time.Millisecond) 203 | 204 | if count != 3 { 205 | t.Errorf("Expected appointmentCheck to be called 3 times, got %d", count) 206 | } 207 | } 208 | 209 | // ---------------- New tests for earliest/latest filtering ---------------- 210 | 211 | func TestAppointmentCheck_EarliestOnly(t *testing.T) { 212 | // earliest = 08:00; appointments at 07:00 (filtered) and 09:00 (valid) 213 | appts := []Appointment{ 214 | {LocationID: 1, StartTimestamp: makeTS(1, 7, 0)}, 215 | {LocationID: 2, StartTimestamp: makeTS(1, 9, 0)}, 216 | } 217 | mockClient := &MockHTTPClient{ 218 | Response: &http.Response{ 219 | StatusCode: http.StatusOK, 220 | Body: io.NopCloser(strings.NewReader(makeJSON(appts))), 221 | }, 222 | } 223 | mockNotifier := &MockNotifier{} 224 | url := "http://example.com" 225 | topic := "topic" 226 | beforeDate := time.Now().Add(72 * time.Hour) 227 | 228 | opts := CheckOptions{ 229 | EarliestMinutes: 8 * 60, 230 | LatestMinutes: -1, 231 | } 232 | 233 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, opts) 234 | 235 | if !mockNotifier.Called { 236 | t.Fatalf("Expected Notify to be called for appointment >= earliest") 237 | } 238 | // Should notify on the first valid (09:00), not the 07:00 one. 239 | if mockNotifier.LocationID != 2 { 240 | t.Errorf("Expected LocationID 2 (09:00), got %d", mockNotifier.LocationID) 241 | } 242 | } 243 | 244 | func TestAppointmentCheck_LatestOnly(t *testing.T) { 245 | // latest = 10:00; appointments at 09:30 (valid) then 11:00 (filtered) 246 | appts := []Appointment{ 247 | {LocationID: 10, StartTimestamp: makeTS(1, 9, 30)}, 248 | {LocationID: 11, StartTimestamp: makeTS(1, 11, 0)}, 249 | } 250 | mockClient := &MockHTTPClient{ 251 | Response: &http.Response{ 252 | StatusCode: http.StatusOK, 253 | Body: io.NopCloser(strings.NewReader(makeJSON(appts))), 254 | }, 255 | } 256 | mockNotifier := &MockNotifier{} 257 | url := "http://example.com" 258 | topic := "topic" 259 | beforeDate := time.Now().Add(72 * time.Hour) 260 | 261 | opts := CheckOptions{ 262 | EarliestMinutes: -1, 263 | LatestMinutes: 10 * 60, 264 | } 265 | 266 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, opts) 267 | 268 | if !mockNotifier.Called { 269 | t.Fatalf("Expected Notify to be called for appointment <= latest") 270 | } 271 | if mockNotifier.LocationID != 10 { 272 | t.Errorf("Expected LocationID 10 (09:30), got %d", mockNotifier.LocationID) 273 | } 274 | } 275 | 276 | func TestAppointmentCheck_BothValidRange(t *testing.T) { 277 | // earliest=08:00, latest=10:00; appointments at 07:30 (filtered), 09:00 (valid), 10:30 (filtered) 278 | appts := []Appointment{ 279 | {LocationID: 101, StartTimestamp: makeTS(1, 7, 30)}, 280 | {LocationID: 102, StartTimestamp: makeTS(1, 9, 0)}, 281 | {LocationID: 103, StartTimestamp: makeTS(1, 10, 30)}, 282 | } 283 | mockClient := &MockHTTPClient{ 284 | Response: &http.Response{ 285 | StatusCode: http.StatusOK, 286 | Body: io.NopCloser(strings.NewReader(makeJSON(appts))), 287 | }, 288 | } 289 | mockNotifier := &MockNotifier{} 290 | url := "http://example.com" 291 | topic := "topic" 292 | beforeDate := time.Now().Add(72 * time.Hour) 293 | 294 | opts := CheckOptions{ 295 | EarliestMinutes: 8 * 60, 296 | LatestMinutes: 10 * 60, 297 | } 298 | 299 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, opts) 300 | 301 | if !mockNotifier.Called { 302 | t.Fatalf("Expected Notify to be called for appointment within [earliest, latest]") 303 | } 304 | if mockNotifier.LocationID != 102 { 305 | t.Errorf("Expected LocationID 102 (09:00), got %d", mockNotifier.LocationID) 306 | } 307 | } 308 | 309 | func TestAppointmentCheck_BothInvalidRange_NoNotify(t *testing.T) { 310 | // earliest=12:00, latest=10:00, impossible to satisfy both; expect no notify 311 | appts := []Appointment{ 312 | {LocationID: 201, StartTimestamp: makeTS(1, 9, 0)}, 313 | {LocationID: 202, StartTimestamp: makeTS(1, 11, 30)}, 314 | {LocationID: 203, StartTimestamp: makeTS(1, 13, 0)}, 315 | } 316 | mockClient := &MockHTTPClient{ 317 | Response: &http.Response{ 318 | StatusCode: http.StatusOK, 319 | Body: io.NopCloser(strings.NewReader(makeJSON(appts))), 320 | }, 321 | } 322 | mockNotifier := &MockNotifier{} 323 | url := "http://example.com" 324 | topic := "topic" 325 | beforeDate := time.Now().Add(72 * time.Hour) 326 | 327 | opts := CheckOptions{ 328 | EarliestMinutes: 12 * 60, 329 | LatestMinutes: 10 * 60, 330 | } 331 | 332 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, opts) 333 | 334 | if mockNotifier.Called { 335 | t.Fatalf("Expected Notify NOT to be called when latest < earliest") 336 | } 337 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
4 | 

5 |