Skip to content

Commit 342432c

Browse files
feat: fix E012 compliance and add bearing/speed ingest validation
1 parent 4d01e39 commit 342432c

2 files changed

Lines changed: 97 additions & 10 deletions

File tree

handlers.go

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ func (r *LocationReport) validate() error {
6464
if r.Timestamp < now-int64(maxTimestampSkew.Seconds()) || r.Timestamp > now+int64(maxTimestampSkew.Seconds()) {
6565
return fmt.Errorf("timestamp must be within %d minutes of server time", int(maxTimestampSkew.Minutes()))
6666
}
67+
if r.Bearing < 0 || r.Bearing > 360 {
68+
return fmt.Errorf("bearing must be between 0 and 360 (inclusive)")
69+
}
70+
if r.Speed < 0 {
71+
return fmt.Errorf("speed must be non-negative")
72+
}
6773
return nil
6874
}
6975

@@ -169,15 +175,20 @@ func buildFeed(vehicles []*VehicleState) *gtfs.FeedMessage {
169175
version := "2.0"
170176
inc := gtfs.FeedHeader_FULL_DATASET
171177

172-
feed := &gtfs.FeedMessage{
173-
Header: &gtfs.FeedHeader{
174-
GtfsRealtimeVersion: &version,
175-
Incrementality: &inc,
176-
Timestamp: &now,
177-
},
178-
}
178+
// E012 (gtfs-realtime-validator): header.timestamp must be >= all entity timestamps.
179+
headerTimestamp := now
179180

181+
var entities []*gtfs.FeedEntity
180182
for _, v := range vehicles {
183+
if v.Timestamp <= 0 {
184+
slog.Warn("buildFeed: skipping vehicle with non-positive timestamp", "vehicle_id", v.VehicleID, "timestamp", v.Timestamp)
185+
continue
186+
}
187+
ts := uint64(v.Timestamp)
188+
if ts > headerTimestamp {
189+
headerTimestamp = ts
190+
}
191+
181192
entity := &gtfs.FeedEntity{
182193
Id: proto.String(v.VehicleID),
183194
Vehicle: &gtfs.VehiclePosition{
@@ -190,18 +201,25 @@ func buildFeed(vehicles []*VehicleState) *gtfs.FeedMessage {
190201
Bearing: proto.Float32(float32(v.Bearing)),
191202
Speed: proto.Float32(float32(v.Speed)),
192203
},
193-
Timestamp: proto.Uint64(uint64(v.Timestamp)),
204+
Timestamp: proto.Uint64(ts),
194205
},
195206
}
196207
if v.TripID != "" {
197208
entity.Vehicle.Trip = &gtfs.TripDescriptor{
198209
TripId: proto.String(v.TripID),
199210
}
200211
}
201-
feed.Entity = append(feed.Entity, entity)
212+
entities = append(entities, entity)
202213
}
203214

204-
return feed
215+
return &gtfs.FeedMessage{
216+
Header: &gtfs.FeedHeader{
217+
GtfsRealtimeVersion: &version,
218+
Incrementality: &inc,
219+
Timestamp: &headerTimestamp,
220+
},
221+
Entity: entities,
222+
}
205223
}
206224

207225
type adminStatusResponse struct {

handlers_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ func TestBuildFeed_WithVehicles(t *testing.T) {
102102

103103
require.NotNil(t, bus2)
104104
assert.Nil(t, bus2.Vehicle.Trip, "bus-2 has no trip, Trip should be nil")
105+
106+
// E012: header timestamp must be >= max entity timestamp.
107+
// 1752566500 is the larger of the two vehicle timestamps above (bus-2).
108+
assert.GreaterOrEqual(t, feed.Header.GetTimestamp(), uint64(1752566500),
109+
"header timestamp must be >= max entity timestamp (E012)")
105110
}
106111

107112
func TestGetFeed_Protobuf(t *testing.T) {
@@ -231,6 +236,36 @@ func TestHandlePostLocation_Validation(t *testing.T) {
231236
loc: LocationReport{VehicleID: "bus-1", Latitude: 1, Longitude: 2, Timestamp: time.Now().Unix() + 600},
232237
want: "timestamp must be within 5 minutes of server time",
233238
},
239+
{
240+
name: "bearing negative",
241+
loc: LocationReport{VehicleID: "bus-1", Latitude: 1, Longitude: 2, Bearing: -1, Timestamp: time.Now().Unix()},
242+
want: "bearing must be between 0 and 360 (inclusive)",
243+
},
244+
{
245+
name: "bearing too high",
246+
loc: LocationReport{VehicleID: "bus-1", Latitude: 1, Longitude: 2, Bearing: 361, Timestamp: time.Now().Unix()},
247+
want: "bearing must be between 0 and 360 (inclusive)",
248+
},
249+
{
250+
name: "speed negative",
251+
loc: LocationReport{VehicleID: "bus-1", Latitude: 1, Longitude: 2, Speed: -5, Timestamp: time.Now().Unix()},
252+
want: "speed must be non-negative",
253+
},
254+
{
255+
name: "bearing just below zero",
256+
loc: LocationReport{VehicleID: "bus-1", Latitude: 1, Longitude: 2, Bearing: -0.001, Timestamp: time.Now().Unix()},
257+
want: "bearing must be between 0 and 360 (inclusive)",
258+
},
259+
{
260+
name: "bearing just above 360",
261+
loc: LocationReport{VehicleID: "bus-1", Latitude: 1, Longitude: 2, Bearing: 360.001, Timestamp: time.Now().Unix()},
262+
want: "bearing must be between 0 and 360 (inclusive)",
263+
},
264+
{
265+
name: "speed just below zero",
266+
loc: LocationReport{VehicleID: "bus-1", Latitude: 1, Longitude: 2, Speed: -0.001, Timestamp: time.Now().Unix()},
267+
want: "speed must be non-negative",
268+
},
234269
}
235270

236271
for _, tc := range tests {
@@ -669,3 +704,37 @@ func TestHandlePostLocation_RateLimitDifferentVehiclesAreIndependent(t *testing.
669704
assert.Equal(t, http.StatusTooManyRequests, postLocationWithClaims(handler, locA, claimsA).Code)
670705
assert.Equal(t, http.StatusTooManyRequests, postLocationWithClaims(handler, locB, claimsB).Code)
671706
}
707+
708+
func TestHandlePostLocation_BearingSpeedBoundaries(t *testing.T) {
709+
tracker := NewTracker(5 * time.Minute)
710+
defer tracker.Stop()
711+
712+
tests := []struct {
713+
name string
714+
sub string
715+
loc LocationReport
716+
}{
717+
{"bearing exactly 0 (north)", "driver-bearing-0", LocationReport{
718+
VehicleID: "bus-1", Latitude: 1, Longitude: 2,
719+
Bearing: 0, Timestamp: time.Now().Unix()}},
720+
{"bearing exactly 360", "driver-bearing-360", LocationReport{
721+
VehicleID: "bus-2", Latitude: 1, Longitude: 2,
722+
Bearing: 360, Timestamp: time.Now().Unix()}},
723+
{"bearing 359.99", "driver-bearing-359", LocationReport{
724+
VehicleID: "bus-3", Latitude: 1, Longitude: 2,
725+
Bearing: 359.99, Timestamp: time.Now().Unix()}},
726+
{"speed exactly 0 (stationary)", "driver-speed-0", LocationReport{
727+
VehicleID: "bus-4", Latitude: 1, Longitude: 2,
728+
Speed: 0, Timestamp: time.Now().Unix()}},
729+
}
730+
731+
for _, tc := range tests {
732+
t.Run(tc.name, func(t *testing.T) {
733+
mStore := &mockStore{}
734+
handler := handlePostLocation(mStore, tracker, NewVehicleRateLimiter())
735+
w := postLocationWithClaims(handler, tc.loc, jwt.MapClaims{"sub": tc.sub})
736+
assert.Equal(t, http.StatusCreated, w.Code, "boundary value should be accepted")
737+
assert.True(t, mStore.saved, "boundary value should have been saved")
738+
})
739+
}
740+
}

0 commit comments

Comments
 (0)