Compare commits

...

4 Commits

Author SHA1 Message Date
cd17d3ae7f Add KeepAlive duration to IaR struct and update related functionality; remove obsolete test file
All checks were successful
Golan Testing / testing (1.25.x, ubuntu-latest) (push) Successful in 24s
2025-09-09 21:40:33 -04:00
12ef7dbd73 Delete .DS_Store
All checks were successful
Golan Testing / testing (1.25.x, ubuntu-latest) (push) Successful in 24s
2025-09-08 13:30:22 -04:00
aaf7994324 Add CI workflow for Golang testing 2025-09-08 13:29:20 -04:00
afa36b85cf Update README.md to enhance project documentation and clarify usage instructions 2025-09-08 13:29:15 -04:00
8 changed files with 268 additions and 195 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -6,23 +6,42 @@ A Go client and utility for interacting with the IamResponding (IaR) API, design
- API client for IaR with keep-alive and alerting support
- Configurable via YAML and Go structs
- Modular codebase for easy extension
- Logging with slog
- Comprehensive test coverage
- File system watcher for MP3 files
- Event-driven architecture with custom event bus
- Structured logging with slog
- HTTP client with automatic cookie management
## Project Structure
```
config.yml # Example configuration file
go.mod # Go module file
go.sum # Go dependencies checksum
main.go # Entry point
bus/ # Event bus logic
client/ # IaR API client and helpers
config/ # Configuration structs and loading
utils/ # Utility functions
watcher/ # File or event watcher logic
bus/
├── bus.go # Custom event bus implementation
└── bus_test.go # Event bus tests
client/
├── client.go # IaR API client
├── response.go # API response structures
├── request.go # API request structures
├── client_test.go # Basic client tests
└── client_api_test.go # API integration tests with mocked responses
config/
├── config.go # Configuration loading and structs
└── config_test.go # Configuration tests
utils/
├── encoder.go # MP3 to base64 encoder
└── encoder_test.go # Encoder tests
watcher/
├── watcher.go # File system watcher for MP3 files
└── watcher_test.go # Watcher tests
```
## Getting Started
### Prerequisites
- Go 1.20 or newer
- Go 1.25 or newer
- Access to the IamResponding API (credentials required)
### Installation
@@ -38,7 +57,24 @@ go mod tidy
```
### Configuration
Edit `config.yml` with your IaR credentials and settings.
Create a `config.yml` file with your IaR credentials and settings:
```yaml
credentials:
secret_key: "your_secret_key"
ttd_api_key: "your_ttd_api_key"
directory: "/path/to/watch/for/mp3/files"
watch_debounce_seconds: 5
logging:
level: "INFO"
```
Configuration options:
- `credentials.secret_key`: IaR API secret key
- `credentials.ttd_api_key`: IaR API key
- `directory`: Directory to watch for MP3 files
- `watch_debounce_seconds`: Debounce time for file events (default: 1)
- `logging.level`: Log level (DEBUG, INFO, WARN, ERROR)
### Usage
Build and run the main application:
@@ -52,12 +88,48 @@ go build -o iar-notificator main.go
./iar-notificator
```
The application will:
1. Load configuration from `config.yml`
2. Start watching the specified directory for MP3 files
3. Send keep-alive signals to IaR API at regular intervals
4. Automatically upload MP3 files as alerts when detected
## API Integration
The client integrates with the IaR API using GraphQL queries and mutations:
- **KeepAlive**: Retrieves pager group information and maintains connection
- **PreAlert**: Creates an alert entry
- **Alert**: Uploads audio data to an existing alert
API responses are parsed and used to populate internal state for subsequent requests.
## Testing
Run all tests:
```sh
go test ./...
```
Run tests for specific packages:
```sh
go test ./client
go test ./config
go test ./bus
go test ./watcher
go test ./utils
```
The test suite includes:
- Unit tests for all core functionality
- Integration tests with mocked HTTP responses
- File system watcher tests
- Configuration loading tests
- Event bus concurrency tests
## Dependencies
- `resty.dev/v3`: HTTP client for API requests
- `github.com/fsnotify/fsnotify`: File system watching
- `gopkg.in/yaml.v3`: YAML configuration parsing
## License
MIT License

View File

@@ -41,6 +41,7 @@ type IaR struct {
PagerGroupID string
PagerGroupName string
Type string
KeepAlive time.Duration
}
// New function creates a New MySubaru API client
@@ -52,6 +53,7 @@ func New(credentials *config.Credentials, logger *slog.Logger) (*Client, error)
PagerGroupID: "",
PagerGroupName: "",
Type: "",
KeepAlive: 15 * time.Minute,
},
isAlive: false,
logger: logger,
@@ -86,6 +88,7 @@ func (c *Client) KeepAlive() error {
c.IAR.PagerGroupID = pg.ID
c.IAR.PagerGroupName = pg.Name
c.IAR.Type = pg.Type
c.IAR.KeepAlive = time.Duration(resp.Data.GetTTDSetting.KeyValue) * time.Second
}
return nil
}
@@ -118,11 +121,11 @@ func (c *Client) Alert(audioBase64 string) error {
}
// ServeKeepAlive starts a goroutine that sends keep-alive requests at the specified interval.
func (c *Client) ServeKeepAlive(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
func (c *Client) ServeKeepAlive(ctx context.Context) {
ticker := time.NewTicker(c.IAR.KeepAlive)
defer ticker.Stop()
slog.Info("Starting keep-alive sender", "interval", interval)
slog.Info("Starting keep-alive sender", slog.Duration("interval", c.IAR.KeepAlive))
for {
select {

View File

@@ -1,179 +0,0 @@
package client_test
import (
"net/http"
"net/http/httptest"
"testing"
"git.savin.nyc/alex/go-iar-notificator/client"
)
const keepAliveResponse = `{
"data": {
"getMutualPagerGroupsList": [
{
"_id": "672783715f6ed07deb857afb",
"subscriberId": 536873008,
"name": "NPFD - 672783715f6ed07deb857afb",
"type": "ab tone",
"tone_tolerance": 0.02,
"gaplength": 0,
"ignore_after": 60,
"record_delay": 2,
"record_seconds": 15,
"release_time": 3,
"playback_during_record": 0,
"post_email_command": "",
"alert_command": "",
"atone": 1656.4,
"btone": 656.1,
"atonelength": 0.9,
"btonelength": 3,
"longtone": null,
"longtonelength": null,
"createDate": "2024-11-03T14:06:41.765Z"
}
],
"getTTDSetting": {
"keySetting": "pollingSeconds",
"keyValue": "900"
}
}
}`
const preAlertResponse = `{
"data": {
"addAlert": {
"_id": "68bb90e0b91373858dd8f214",
"textAlert": "NPFD Received at 21:39:44 09/05/2025",
"pagerGroup": "NPFD",
"subscriberId": 536873008
}
}
}`
const alertResponse = `{
"data": {
"addAlert": {
"_id": "68bb90e0b91373858dd8f214",
"textAlert": "NPFD Received at 21:40:05 09/05/2025",
"pagerGroup": "NPFD",
"audioUrl": "https://storage.iamresponding.com/v3/xzPAzU4ZEXlRtMLtj2NlquvalB26xQfS.mp3",
"subscriberId": 536873008
}
}
}`
// patchClientHTTP replaces the HTTPClient's transport with a test server
func patchClientHTTP(c *client.Client, handler http.HandlerFunc) {
ts := httptest.NewServer(handler)
c.HTTPClient.SetBaseURL(ts.URL)
}
func TestClient_KeepAlive_Success(t *testing.T) {
logger := mockLogger()
creds := mockCredentials()
c, err := client.New(creds, logger)
if err != nil {
t.Fatalf("New() error: %v", err)
}
patchClientHTTP(c, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(keepAliveResponse))
})
err = c.KeepAlive()
if err != nil {
t.Errorf("KeepAlive() error = %v, want nil", err)
}
if !c.IsAlive() {
t.Error("expected client to be alive after successful KeepAlive")
}
// Check if IAR is set
if c.IAR.PagerGroupID != "672783715f6ed07deb857afb" {
t.Errorf("expected PagerGroupID to be set, got %s", c.IAR.PagerGroupID)
}
}
func TestClient_KeepAlive_Failure(t *testing.T) {
logger := mockLogger()
creds := mockCredentials()
c, err := client.New(creds, logger)
if err != nil {
t.Fatalf("New() error: %v", err)
}
patchClientHTTP(c, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error": "fail"}`))
})
err = c.KeepAlive()
if err == nil {
t.Error("expected error from KeepAlive, got nil")
}
if c.IsAlive() {
t.Error("expected client to not be alive after failed KeepAlive")
}
}
func TestClient_PreAlert_Success(t *testing.T) {
logger := mockLogger()
creds := mockCredentials()
c, err := client.New(creds, logger)
if err != nil {
t.Fatalf("New() error: %v", err)
}
// Set IAR
c.IAR.PagerGroupID = "test_group"
patchClientHTTP(c, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(preAlertResponse))
})
alertID, err := c.PreAlert()
if err != nil {
t.Errorf("PreAlert() error = %v, want nil", err)
}
expectedID := "68bb90e0b91373858dd8f214"
if alertID != expectedID {
t.Errorf("expected alertID %s, got %s", expectedID, alertID)
}
}
func TestClient_Alert_Success(t *testing.T) {
logger := mockLogger()
creds := mockCredentials()
c, err := client.New(creds, logger)
if err != nil {
t.Fatalf("New() error: %v", err)
}
// Set IAR
c.IAR.PagerGroupID = "test_group"
callCount := 0
patchClientHTTP(c, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if callCount == 0 {
// First call is preAlert
w.Write([]byte(preAlertResponse))
} else {
// Second call is Alert
w.Write([]byte(alertResponse))
}
callCount++
})
err = c.Alert("base64_audio")
if err != nil {
t.Errorf("Alert() error = %v, want nil", err)
}
if callCount != 2 {
t.Errorf("expected 2 HTTP calls, got %d", callCount)
}
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
@@ -11,6 +13,62 @@ import (
"git.savin.nyc/alex/go-iar-notificator/config"
)
const keepAliveResponse = `{
"data": {
"getMutualPagerGroupsList": [
{
"_id": "672783715f6ed07deb857afb",
"subscriberId": 536873008,
"name": "NPFD - 672783715f6ed07deb857afb",
"type": "ab tone",
"tone_tolerance": 0.02,
"gaplength": 0,
"ignore_after": 60,
"record_delay": 2,
"record_seconds": 15,
"release_time": 3,
"playback_during_record": 0,
"post_email_command": "",
"alert_command": "",
"atone": 1656.4,
"btone": 656.1,
"atonelength": 0.9,
"btonelength": 3,
"longtone": null,
"longtonelength": null,
"createDate": "2024-11-03T14:06:41.765Z"
}
],
"getTTDSetting": {
"keySetting": "pollingSeconds",
"keyValue": "900"
}
}
}`
const preAlertResponse = `{
"data": {
"addAlert": {
"_id": "68bb90e0b91373858dd8f214",
"textAlert": "NPFD Received at 21:39:44 09/05/2025",
"pagerGroup": "NPFD",
"subscriberId": 536873008
}
}
}`
const alertResponse = `{
"data": {
"addAlert": {
"_id": "68bb90e0b91373858dd8f214",
"textAlert": "NPFD Received at 21:40:05 09/05/2025",
"pagerGroup": "NPFD",
"audioUrl": "https://storage.iamresponding.com/v3/xzPAzU4ZEXlRtMLtj2NlquvalB26xQfS.mp3",
"subscriberId": 536873008
}
}
}`
// mockLogger returns a no-op slog.Logger for testing
func mockLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{}))
@@ -50,9 +108,128 @@ func TestServeKeepAliveCancel(t *testing.T) {
creds := mockCredentials()
c, _ := client.New(creds, logger)
ctx, cancel := context.WithCancel(context.Background())
go c.ServeKeepAlive(ctx, 10*time.Millisecond)
go c.ServeKeepAlive(ctx)
// Let it run briefly
time.Sleep(20 * time.Millisecond)
cancel()
// No panic or deadlock expected
}
// patchClientHTTP replaces the HTTPClient's transport with a test server
func patchClientHTTP(c *client.Client, handler http.HandlerFunc) {
ts := httptest.NewServer(handler)
c.HTTPClient.SetBaseURL(ts.URL)
}
func TestClient_KeepAlive_Success(t *testing.T) {
logger := mockLogger()
creds := mockCredentials()
c, err := client.New(creds, logger)
if err != nil {
t.Fatalf("New() error: %v", err)
}
patchClientHTTP(c, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(keepAliveResponse))
})
err = c.KeepAlive()
if err != nil {
t.Errorf("KeepAlive() error = %v, want nil", err)
}
if !c.IsAlive() {
t.Error("expected client to be alive after successful KeepAlive")
}
// Check if IAR is set
if c.IAR.PagerGroupID != "672783715f6ed07deb857afb" {
t.Errorf("expected PagerGroupID to be set, got %s", c.IAR.PagerGroupID)
}
// Check if KeepAlive duration is set from API response (900 seconds = 15 minutes)
expectedDuration := 900 * time.Second
if c.IAR.KeepAlive != expectedDuration {
t.Errorf("expected KeepAlive duration to be %v, got %v", expectedDuration, c.IAR.KeepAlive)
}
}
func TestClient_KeepAlive_Failure(t *testing.T) {
logger := mockLogger()
creds := mockCredentials()
c, err := client.New(creds, logger)
if err != nil {
t.Fatalf("New() error: %v", err)
}
patchClientHTTP(c, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error": "fail"}`))
})
err = c.KeepAlive()
if err == nil {
t.Error("expected error from KeepAlive, got nil")
}
if c.IsAlive() {
t.Error("expected client to not be alive after failed KeepAlive")
}
}
func TestClient_PreAlert_Success(t *testing.T) {
logger := mockLogger()
creds := mockCredentials()
c, err := client.New(creds, logger)
if err != nil {
t.Fatalf("New() error: %v", err)
}
// Set IAR
c.IAR.PagerGroupID = "test_group"
patchClientHTTP(c, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(preAlertResponse))
})
alertID, err := c.PreAlert()
if err != nil {
t.Errorf("PreAlert() error = %v, want nil", err)
}
expectedID := "68bb90e0b91373858dd8f214"
if alertID != expectedID {
t.Errorf("expected alertID %s, got %s", expectedID, alertID)
}
}
func TestClient_Alert_Success(t *testing.T) {
logger := mockLogger()
creds := mockCredentials()
c, err := client.New(creds, logger)
if err != nil {
t.Fatalf("New() error: %v", err)
}
// Set IAR
c.IAR.PagerGroupID = "test_group"
callCount := 0
patchClientHTTP(c, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if callCount == 0 {
// First call is preAlert
w.Write([]byte(preAlertResponse))
} else {
// Second call is Alert
w.Write([]byte(alertResponse))
}
callCount++
})
err = c.Alert("base64_audio")
if err != nil {
t.Errorf("Alert() error = %v, want nil", err)
}
if callCount != 2 {
t.Errorf("expected 2 HTTP calls, got %d", callCount)
}
}

View File

@@ -30,7 +30,7 @@ type PagerGroup struct {
type TTDSetting struct {
KeySetting string `json:"keySetting"`
KeyValue string `json:"keyValue"`
KeyValue int `json:"keyValue,string"`
}
type AddAlert struct {

View File

@@ -70,14 +70,14 @@ func main() {
// Start directory watcher in a goroutine
go watcher.WatchDirectory(ctx, config.Directory, eb, logger)
// Start keep-alive sender in a goroutine
go iar.ServeKeepAlive(ctx, 15*time.Minute)
err = iar.KeepAlive()
if err != nil {
slog.Error("Initial keep-alive failed", "error", err)
}
// Start keep-alive sender in a goroutine
go iar.ServeKeepAlive(ctx)
// Handle OS signals for shutdown
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)