Skip to content

Commit 1845bbd

Browse files
authored
Merge pull request #21 from doneill/task/jdo-40-patrols-list
Patrols Command
2 parents 7a80c1c + f5995a5 commit 1845bbd

File tree

4 files changed

+461
-0
lines changed

4 files changed

+461
-0
lines changed

api/apipatrols.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/url"
7+
"time"
8+
)
9+
10+
// ----------------------------------------------
11+
// Patrol types
12+
// ----------------------------------------------
13+
14+
type PatrolsResponse struct {
15+
Data struct {
16+
Count int `json:"count"`
17+
Next string `json:"next"`
18+
Previous string `json:"previous"`
19+
Results []Patrol `json:"results"`
20+
} `json:"data"`
21+
Status struct {
22+
Code int `json:"code"`
23+
Message string `json:"message"`
24+
} `json:"status"`
25+
}
26+
27+
type Patrol struct {
28+
ID string `json:"id"`
29+
SerialNumber int `json:"serial_number"`
30+
State string `json:"state"`
31+
Title *string `json:"title"`
32+
PatrolSegments []PatrolSegment `json:"patrol_segments"`
33+
}
34+
35+
type PatrolSegment struct {
36+
Leader *struct {
37+
Name string `json:"name"`
38+
} `json:"leader"`
39+
PatrolType string `json:"patrol_type"`
40+
StartLocation *Location `json:"start_location"`
41+
TimeRange TimeRange `json:"time_range"`
42+
}
43+
44+
type Location struct {
45+
Latitude float64 `json:"latitude"`
46+
Longitude float64 `json:"longitude"`
47+
}
48+
49+
type TimeRange struct {
50+
StartTime *string `json:"start_time"`
51+
EndTime *string `json:"end_time"`
52+
}
53+
54+
type DateRangeFilter struct {
55+
DateRange struct {
56+
Lower string `json:"lower"`
57+
Upper string `json:"upper"`
58+
} `json:"date_range"`
59+
PatrolsOverlapDaterange bool `json:"patrols_overlap_daterange"`
60+
}
61+
62+
// ----------------------------------------------
63+
// Client methods
64+
// ----------------------------------------------
65+
66+
func (c *Client) Patrols(days int) (*PatrolsResponse, error) {
67+
var endpoint string
68+
69+
if days > 0 {
70+
now := time.Now().UTC()
71+
upper := now
72+
lower := now.AddDate(0, 0, -days)
73+
74+
filter := DateRangeFilter{
75+
PatrolsOverlapDaterange: false,
76+
}
77+
filter.DateRange.Lower = lower.Format("2006-01-02T15:04:05.000Z")
78+
filter.DateRange.Upper = upper.Format("2006-01-02T15:04:05.000Z")
79+
80+
filterJSON, err := json.Marshal(filter)
81+
if err != nil {
82+
return nil, fmt.Errorf("failed to marshal date filter: %w", err)
83+
}
84+
85+
params := url.Values{}
86+
params.Add("filter", string(filterJSON))
87+
params.Add("exclude_empty_patrols", "true")
88+
89+
endpoint = fmt.Sprintf("%s?%s", API_PATROLS, params.Encode())
90+
} else {
91+
endpoint = fmt.Sprintf("%s?exclude_empty_patrols=true", API_PATROLS)
92+
}
93+
94+
req, err := c.newRequest("GET", endpoint, false)
95+
if err != nil {
96+
return nil, fmt.Errorf("failed to create patrols request: %w", err)
97+
}
98+
99+
var response PatrolsResponse
100+
if err := c.doRequest(req, &response); err != nil {
101+
return nil, fmt.Errorf("failed to get patrols: %w", err)
102+
}
103+
104+
return &response, nil
105+
}

api/ersvc.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ const API_V1 = "/api/v1.0"
1616

1717
const API_AUTH = "/oauth2/token"
1818

19+
const API_ACTIVITY = API_V1 + "/activity"
20+
21+
const API_PATROLS = API_ACTIVITY + "/patrols"
22+
1923
const API_SUBJECT = API_V1 + "/subject"
2024

2125
const API_SUBJECTS = API_V1 + "/subjects"

api/patrols_test.go

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
"time"
10+
)
11+
12+
func TestPatrols(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
days int
16+
mockResponse string
17+
expectedError bool
18+
validateResult func(*testing.T, *PatrolsResponse)
19+
}{
20+
{
21+
name: "successful response without date filter",
22+
days: 0,
23+
mockResponse: `{
24+
"data": {
25+
"count": 1,
26+
"next": null,
27+
"previous": null,
28+
"results": [
29+
{
30+
"id": "test123",
31+
"serial_number": 1001,
32+
"state": "open",
33+
"title": "Test Patrol",
34+
"patrol_segments": [
35+
{
36+
"leader": {"name": "John Doe"},
37+
"patrol_type": "boat_patrol",
38+
"start_location": {"latitude": 1.234, "longitude": 5.678},
39+
"time_range": {
40+
"start_time": "2025-01-15T10:00:00.000Z",
41+
"end_time": "2025-01-15T11:00:00.000Z"
42+
}
43+
}
44+
]
45+
}
46+
]
47+
},
48+
"status": {
49+
"code": 200,
50+
"message": "OK"
51+
}
52+
}`,
53+
expectedError: false,
54+
validateResult: func(t *testing.T, response *PatrolsResponse) {
55+
if response == nil {
56+
t.Fatal("Expected non-nil response")
57+
}
58+
if len(response.Data.Results) != 1 {
59+
t.Errorf("Expected 1 result, got %d", len(response.Data.Results))
60+
}
61+
patrol := response.Data.Results[0]
62+
if patrol.ID != "test123" {
63+
t.Errorf("Expected ID 'test123', got '%s'", patrol.ID)
64+
}
65+
if patrol.SerialNumber != 1001 {
66+
t.Errorf("Expected serial number 1001, got %d", patrol.SerialNumber)
67+
}
68+
if len(patrol.PatrolSegments) == 0 {
69+
t.Fatal("Expected at least one patrol segment")
70+
}
71+
if patrol.PatrolSegments[0].Leader == nil {
72+
t.Fatal("Expected non-nil leader")
73+
}
74+
if patrol.PatrolSegments[0].Leader.Name != "John Doe" {
75+
t.Errorf("Expected leader name 'John Doe', got '%s'", patrol.PatrolSegments[0].Leader.Name)
76+
}
77+
},
78+
},
79+
{
80+
name: "successful response with date filter",
81+
days: 7,
82+
mockResponse: `{
83+
"data": {
84+
"count": 1,
85+
"results": [
86+
{
87+
"id": "test456",
88+
"serial_number": 1002,
89+
"state": "closed"
90+
}
91+
]
92+
},
93+
"status": {
94+
"code": 200,
95+
"message": "OK"
96+
}
97+
}`,
98+
expectedError: false,
99+
validateResult: func(t *testing.T, response *PatrolsResponse) {
100+
if response == nil {
101+
t.Fatal("Expected non-nil response")
102+
}
103+
if len(response.Data.Results) != 1 {
104+
t.Errorf("Expected 1 result, got %d", len(response.Data.Results))
105+
}
106+
},
107+
},
108+
{
109+
name: "error response",
110+
days: 0,
111+
mockResponse: `{"status": {"code": 500, "message": "Internal Server Error"}}`,
112+
expectedError: true,
113+
validateResult: nil,
114+
},
115+
}
116+
117+
for _, tt := range tests {
118+
t.Run(tt.name, func(t *testing.T) {
119+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
120+
// Validate request
121+
if r.Method != http.MethodGet {
122+
t.Errorf("Expected GET request, got %s", r.Method)
123+
}
124+
125+
if tt.days > 0 {
126+
if !strings.Contains(r.URL.String(), "filter=") {
127+
t.Error("Expected filter parameter in URL for date-filtered request")
128+
}
129+
if !strings.Contains(r.URL.String(), "patrols_overlap_daterange") {
130+
t.Error("Expected patrols_overlap_daterange in filter")
131+
}
132+
}
133+
134+
// Return mock response
135+
w.Header().Set("Content-Type", "application/json")
136+
if strings.Contains(tt.mockResponse, `"code": 500`) {
137+
w.WriteHeader(http.StatusInternalServerError)
138+
}
139+
if _, err := w.Write([]byte(tt.mockResponse)); err != nil {
140+
t.Errorf("Failed to write response: %v", err)
141+
}
142+
}))
143+
defer server.Close()
144+
145+
client := ERClient("test", "test-token", server.URL)
146+
response, err := client.Patrols(tt.days)
147+
148+
if tt.expectedError {
149+
if err == nil {
150+
t.Error("Expected error, got nil")
151+
}
152+
return
153+
}
154+
155+
if err != nil {
156+
t.Errorf("Unexpected error: %v", err)
157+
return
158+
}
159+
160+
if tt.validateResult != nil {
161+
tt.validateResult(t, response)
162+
}
163+
})
164+
}
165+
}
166+
167+
func TestDateRangeFilter(t *testing.T) {
168+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
169+
filterStr := r.URL.Query().Get("filter")
170+
if filterStr == "" {
171+
t.Error("Expected filter parameter in URL")
172+
return
173+
}
174+
175+
var filter DateRangeFilter
176+
err := json.Unmarshal([]byte(filterStr), &filter)
177+
if err != nil {
178+
t.Errorf("Failed to parse filter JSON: %v", err)
179+
return
180+
}
181+
182+
// Validate date format
183+
_, err = time.Parse(time.RFC3339, filter.DateRange.Lower)
184+
if err != nil {
185+
t.Errorf("Invalid lower date format: %v", err)
186+
}
187+
188+
_, err = time.Parse(time.RFC3339, filter.DateRange.Upper)
189+
if err != nil {
190+
t.Errorf("Invalid upper date format: %v", err)
191+
}
192+
193+
if filter.PatrolsOverlapDaterange {
194+
t.Error("Expected PatrolsOverlapDaterange to be false")
195+
}
196+
197+
// Return a valid response
198+
w.Header().Set("Content-Type", "application/json")
199+
if _, err := w.Write([]byte(`{"data":{"count":0,"results":[]},"status":{"code":200,"message":"OK"}}`)); err != nil {
200+
t.Errorf("Failed to write response: %v", err)
201+
}
202+
}))
203+
defer server.Close()
204+
205+
client := ERClient("test", "test-token", server.URL)
206+
_, err := client.Patrols(7)
207+
if err != nil {
208+
t.Errorf("Unexpected error: %v", err)
209+
}
210+
}

0 commit comments

Comments
 (0)