Files
pamietnik/backend/internal/api/ingest.go
Christoph K. 034d16e059
Some checks failed
Deploy to NAS / deploy (push) Failing after 14s
Add Go backend unit and handler tests; wire test step into CI
- Introduce store interfaces (TrackpointStorer, StopStorer, SuggestionStorer)
  in internal/db/interfaces.go so handlers can be tested without a real DB.
- Refactor HandleSingleTrackpoint, HandleBatchTrackpoints, HandleListDays,
  HandleListTrackpoints, HandleListStops, HandleListSuggestions to accept
  the new interfaces instead of concrete *db.*Store pointers (no behaviour
  change; concrete types satisfy the interfaces implicitly).
- internal/api/ingest_test.go: 13 handler tests covering happy path,
  invalid JSON, invalid timestamp, missing event_id/device_id, out-of-range
  lat/lon, empty/oversized batch, store errors, and idempotency (single + batch).
- internal/api/query_test.go: 14 handler tests covering missing query params
  (400) and empty-result-is-array guarantees for all four query endpoints.
- internal/auth/auth_test.go: 5 unit tests for HashPassword / VerifyPassword
  (correct password, wrong password, empty password, malformed hash, salt
  uniqueness).
- internal/db/trackpoints_test.go: 6 unit tests for the validateTrackpoint
  helper (happy path, missing fields, coordinate bounds, invalid source).
- .gitea/workflows/deploy.yml: add "Test" step (go test ./...) before
  "Build & Deploy" so a failing test aborts the deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 19:07:02 +02:00

138 lines
3.8 KiB
Go

package api
import (
"encoding/json"
"net/http"
"time"
"github.com/jacek/pamietnik/backend/internal/db"
"github.com/jacek/pamietnik/backend/internal/domain"
)
// Compile-time check: *db.TrackpointStore must satisfy db.TrackpointStorer.
var _ db.TrackpointStorer = (*db.TrackpointStore)(nil)
type trackpointInput struct {
EventID string `json:"event_id"`
DeviceID string `json:"device_id"`
TripID string `json:"trip_id"`
Timestamp string `json:"timestamp"` // RFC3339
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Source string `json:"source"`
Note string `json:"note,omitempty"`
AccuracyM *float64 `json:"accuracy_m,omitempty"`
SpeedMps *float64 `json:"speed_mps,omitempty"`
BearingDeg *float64 `json:"bearing_deg,omitempty"`
AltitudeM *float64 `json:"altitude_m,omitempty"`
}
func (t trackpointInput) toDomain() (domain.Trackpoint, error) {
ts, err := time.Parse(time.RFC3339, t.Timestamp)
if err != nil {
return domain.Trackpoint{}, err
}
src := t.Source
if src == "" {
src = "gps"
}
return domain.Trackpoint{
EventID: t.EventID,
DeviceID: t.DeviceID,
TripID: t.TripID,
Timestamp: ts,
Lat: t.Lat,
Lon: t.Lon,
Source: src,
Note: t.Note,
AccuracyM: t.AccuracyM,
SpeedMps: t.SpeedMps,
BearingDeg: t.BearingDeg,
AltitudeM: t.AltitudeM,
}, nil
}
type batchResponse struct {
ServerTime string `json:"server_time"`
AcceptedIDs []string `json:"accepted_ids"`
Rejected []db.RejectedItem `json:"rejected"`
}
// HandleSingleTrackpoint handles POST /v1/trackpoints
func HandleSingleTrackpoint(store db.TrackpointStorer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var input trackpointInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid JSON")
return
}
point, err := input.toDomain()
if err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid timestamp: "+err.Error())
return
}
userID := userIDFromContext(r.Context())
accepted, rejected, err := store.UpsertBatch(r.Context(), userID, []domain.Trackpoint{point})
if err != nil {
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "database error")
return
}
writeJSON(w, http.StatusOK, batchResponse{
ServerTime: time.Now().UTC().Format(time.RFC3339),
AcceptedIDs: accepted,
Rejected: rejected,
})
}
}
// HandleBatchTrackpoints handles POST /v1/trackpoints:batch
func HandleBatchTrackpoints(store db.TrackpointStorer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var inputs []trackpointInput
if err := json.NewDecoder(r.Body).Decode(&inputs); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid JSON")
return
}
if len(inputs) == 0 {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "empty batch")
return
}
if len(inputs) > 500 {
writeError(w, http.StatusBadRequest, "TOO_LARGE", "batch exceeds 500 items")
return
}
points := make([]domain.Trackpoint, 0, len(inputs))
var parseRejected []db.RejectedItem
for _, inp := range inputs {
p, err := inp.toDomain()
if err != nil {
parseRejected = append(parseRejected, db.RejectedItem{
EventID: inp.EventID,
Code: "INVALID_TIMESTAMP",
Message: err.Error(),
})
continue
}
points = append(points, p)
}
userID := userIDFromContext(r.Context())
accepted, rejected, err := store.UpsertBatch(r.Context(), userID, points)
if err != nil {
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "database error")
return
}
rejected = append(rejected, parseRejected...)
writeJSON(w, http.StatusOK, batchResponse{
ServerTime: time.Now().UTC().Format(time.RFC3339),
AcceptedIDs: accepted,
Rejected: rejected,
})
}
}