Some checks failed
Deploy to NAS / deploy (push) Failing after 14s
- 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>
381 lines
12 KiB
Go
381 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/jacek/pamietnik/backend/internal/db"
|
|
"github.com/jacek/pamietnik/backend/internal/domain"
|
|
)
|
|
|
|
// fakeTrackpointStore is an in-memory implementation of db.TrackpointStorer for tests.
|
|
type fakeTrackpointStore struct {
|
|
// stored maps event_id → Trackpoint (simulates unique constraint)
|
|
stored map[string]domain.Trackpoint
|
|
// forceErr causes UpsertBatch to return an error when set
|
|
forceErr error
|
|
}
|
|
|
|
func newFakeTrackpointStore() *fakeTrackpointStore {
|
|
return &fakeTrackpointStore{stored: make(map[string]domain.Trackpoint)}
|
|
}
|
|
|
|
func (f *fakeTrackpointStore) UpsertBatch(_ context.Context, _ string, points []domain.Trackpoint) ([]string, []db.RejectedItem, error) {
|
|
if f.forceErr != nil {
|
|
return nil, nil, f.forceErr
|
|
}
|
|
var accepted []string
|
|
var rejected []db.RejectedItem
|
|
for _, p := range points {
|
|
// Validate
|
|
if p.EventID == "" {
|
|
rejected = append(rejected, db.RejectedItem{EventID: p.EventID, Code: "VALIDATION_ERROR", Message: "event_id is required"})
|
|
continue
|
|
}
|
|
if p.DeviceID == "" {
|
|
rejected = append(rejected, db.RejectedItem{EventID: p.EventID, Code: "VALIDATION_ERROR", Message: "device_id is required"})
|
|
continue
|
|
}
|
|
if p.Lat < -90 || p.Lat > 90 {
|
|
rejected = append(rejected, db.RejectedItem{EventID: p.EventID, Code: "VALIDATION_ERROR", Message: "lat out of range"})
|
|
continue
|
|
}
|
|
if p.Lon < -180 || p.Lon > 180 {
|
|
rejected = append(rejected, db.RejectedItem{EventID: p.EventID, Code: "VALIDATION_ERROR", Message: "lon out of range"})
|
|
continue
|
|
}
|
|
// Idempotency: already stored → count as accepted (no duplicate insert)
|
|
if _, exists := f.stored[p.EventID]; !exists {
|
|
f.stored[p.EventID] = p
|
|
}
|
|
accepted = append(accepted, p.EventID)
|
|
}
|
|
return accepted, rejected, nil
|
|
}
|
|
|
|
func (f *fakeTrackpointStore) ListByDate(_ context.Context, _, _ string) ([]domain.Trackpoint, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeTrackpointStore) ListDays(_ context.Context, _, _, _ string) ([]domain.DaySummary, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// authContext injects a fake user_id into the request context, simulating a logged-in session.
|
|
func authContext(r *http.Request) *http.Request {
|
|
return r.WithContext(contextWithUserID(r.Context(), "user-test"))
|
|
}
|
|
|
|
// --- HandleSingleTrackpoint tests ---
|
|
|
|
func TestHandleSingleTrackpoint_HappyPath(t *testing.T) {
|
|
store := newFakeTrackpointStore()
|
|
handler := HandleSingleTrackpoint(store)
|
|
|
|
body := `{
|
|
"event_id": "evt-001",
|
|
"device_id": "dev-001",
|
|
"trip_id": "trip-1",
|
|
"timestamp": "2024-06-01T12:00:00Z",
|
|
"lat": 52.5,
|
|
"lon": 13.4,
|
|
"source": "gps"
|
|
}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints", strings.NewReader(body))
|
|
req = authContext(req)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp batchResponse
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if len(resp.AcceptedIDs) != 1 || resp.AcceptedIDs[0] != "evt-001" {
|
|
t.Errorf("expected accepted_ids=[evt-001], got %v", resp.AcceptedIDs)
|
|
}
|
|
if len(resp.Rejected) != 0 {
|
|
t.Errorf("expected no rejected, got %v", resp.Rejected)
|
|
}
|
|
}
|
|
|
|
func TestHandleSingleTrackpoint_InvalidJSON(t *testing.T) {
|
|
handler := HandleSingleTrackpoint(newFakeTrackpointStore())
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints", strings.NewReader("{bad json"))
|
|
req = authContext(req)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleSingleTrackpoint_InvalidTimestamp(t *testing.T) {
|
|
handler := HandleSingleTrackpoint(newFakeTrackpointStore())
|
|
|
|
body := `{"event_id":"e1","device_id":"d1","timestamp":"not-a-date","lat":10,"lon":10}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints", strings.NewReader(body))
|
|
req = authContext(req)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleSingleTrackpoint_MissingEventID(t *testing.T) {
|
|
store := newFakeTrackpointStore()
|
|
handler := HandleSingleTrackpoint(store)
|
|
|
|
body := `{"device_id":"dev-1","timestamp":"2024-01-01T00:00:00Z","lat":10,"lon":10,"source":"gps"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints", strings.NewReader(body))
|
|
req = authContext(req)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 (rejected in payload), got %d", rec.Code)
|
|
}
|
|
var resp batchResponse
|
|
json.NewDecoder(rec.Body).Decode(&resp)
|
|
if len(resp.Rejected) == 0 {
|
|
t.Error("expected missing event_id to appear in rejected list")
|
|
}
|
|
}
|
|
|
|
func TestHandleSingleTrackpoint_InvalidLatLon(t *testing.T) {
|
|
cases := []struct{ lat, lon float64 }{
|
|
{91, 0},
|
|
{-91, 0},
|
|
{0, 181},
|
|
{0, -181},
|
|
}
|
|
for _, c := range cases {
|
|
body := fmt.Sprintf(`{"event_id":"e1","device_id":"d1","timestamp":"2024-01-01T00:00:00Z","lat":%v,"lon":%v,"source":"gps"}`, c.lat, c.lon)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints", strings.NewReader(body))
|
|
req = authContext(req)
|
|
rec := httptest.NewRecorder()
|
|
|
|
HandleSingleTrackpoint(newFakeTrackpointStore()).ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 with rejected payload for lat=%v lon=%v, got %d", c.lat, c.lon, rec.Code)
|
|
}
|
|
var resp batchResponse
|
|
json.NewDecoder(rec.Body).Decode(&resp)
|
|
if len(resp.Rejected) == 0 {
|
|
t.Errorf("expected invalid lat/lon to appear in rejected list (lat=%v lon=%v)", c.lat, c.lon)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleSingleTrackpoint_IdempotencyDuplicateEventID(t *testing.T) {
|
|
store := newFakeTrackpointStore()
|
|
handler := HandleSingleTrackpoint(store)
|
|
|
|
body := `{"event_id":"evt-dup","device_id":"dev-1","timestamp":"2024-01-01T00:00:00Z","lat":10,"lon":10,"source":"gps"}`
|
|
|
|
sendRequest := func() batchResponse {
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints", strings.NewReader(body))
|
|
req = authContext(req)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
var resp batchResponse
|
|
json.NewDecoder(rec.Body).Decode(&resp)
|
|
return resp
|
|
}
|
|
|
|
r1 := sendRequest()
|
|
r2 := sendRequest()
|
|
|
|
// Both calls must succeed and return the same accepted_ids
|
|
if len(r1.AcceptedIDs) != 1 || r1.AcceptedIDs[0] != "evt-dup" {
|
|
t.Errorf("first call: expected [evt-dup], got %v", r1.AcceptedIDs)
|
|
}
|
|
if len(r2.AcceptedIDs) != 1 || r2.AcceptedIDs[0] != "evt-dup" {
|
|
t.Errorf("second call: expected [evt-dup] (idempotent), got %v", r2.AcceptedIDs)
|
|
}
|
|
// Store must not contain duplicate entries
|
|
if len(store.stored) != 1 {
|
|
t.Errorf("expected exactly 1 stored trackpoint, got %d", len(store.stored))
|
|
}
|
|
}
|
|
|
|
func TestHandleSingleTrackpoint_StoreError(t *testing.T) {
|
|
store := newFakeTrackpointStore()
|
|
store.forceErr = fmt.Errorf("connection reset")
|
|
handler := HandleSingleTrackpoint(store)
|
|
|
|
body := `{"event_id":"e1","device_id":"d1","timestamp":"2024-01-01T00:00:00Z","lat":10,"lon":10}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints", strings.NewReader(body))
|
|
req = authContext(req)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected 500 on store error, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// --- HandleBatchTrackpoints tests ---
|
|
|
|
func validBatchBody(n int) string {
|
|
items := make([]string, n)
|
|
for i := range items {
|
|
items[i] = fmt.Sprintf(`{"event_id":"evt-%d","device_id":"dev-1","timestamp":"2024-01-01T00:00:00Z","lat":10,"lon":10,"source":"gps"}`, i)
|
|
}
|
|
return "[" + strings.Join(items, ",") + "]"
|
|
}
|
|
|
|
func TestHandleBatchTrackpoints_HappyPath(t *testing.T) {
|
|
handler := HandleBatchTrackpoints(newFakeTrackpointStore())
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints:batch", strings.NewReader(validBatchBody(3)))
|
|
req = authContext(req)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp batchResponse
|
|
json.NewDecoder(rec.Body).Decode(&resp)
|
|
if len(resp.AcceptedIDs) != 3 {
|
|
t.Errorf("expected 3 accepted, got %d", len(resp.AcceptedIDs))
|
|
}
|
|
}
|
|
|
|
func TestHandleBatchTrackpoints_EmptyBatch(t *testing.T) {
|
|
handler := HandleBatchTrackpoints(newFakeTrackpointStore())
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints:batch", strings.NewReader("[]"))
|
|
req = authContext(req)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for empty batch, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleBatchTrackpoints_ExceedsLimit(t *testing.T) {
|
|
handler := HandleBatchTrackpoints(newFakeTrackpointStore())
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints:batch", strings.NewReader(validBatchBody(501)))
|
|
req = authContext(req)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for batch > 500, got %d", rec.Code)
|
|
}
|
|
var errResp errorResponse
|
|
json.NewDecoder(rec.Body).Decode(&errResp)
|
|
if errResp.Code != "TOO_LARGE" {
|
|
t.Errorf("expected code TOO_LARGE, got %q", errResp.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleBatchTrackpoints_InvalidJSON(t *testing.T) {
|
|
handler := HandleBatchTrackpoints(newFakeTrackpointStore())
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints:batch", strings.NewReader("{not array}"))
|
|
req = authContext(req)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleBatchTrackpoints_PartialInvalidTimestamp(t *testing.T) {
|
|
handler := HandleBatchTrackpoints(newFakeTrackpointStore())
|
|
|
|
// First item has valid timestamp, second has invalid
|
|
body := `[
|
|
{"event_id":"e1","device_id":"d1","timestamp":"2024-01-01T00:00:00Z","lat":10,"lon":10},
|
|
{"event_id":"e2","device_id":"d1","timestamp":"not-a-date","lat":10,"lon":10}
|
|
]`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints:batch", bytes.NewBufferString(body))
|
|
req = authContext(req)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
var resp batchResponse
|
|
json.NewDecoder(rec.Body).Decode(&resp)
|
|
if len(resp.AcceptedIDs) != 1 {
|
|
t.Errorf("expected 1 accepted, got %d", len(resp.AcceptedIDs))
|
|
}
|
|
if len(resp.Rejected) != 1 || resp.Rejected[0].EventID != "e2" {
|
|
t.Errorf("expected e2 in rejected, got %v", resp.Rejected)
|
|
}
|
|
}
|
|
|
|
func TestHandleBatchTrackpoints_IdempotencyBatchSentTwice(t *testing.T) {
|
|
store := newFakeTrackpointStore()
|
|
handler := HandleBatchTrackpoints(store)
|
|
|
|
body := validBatchBody(5)
|
|
|
|
sendBatch := func() batchResponse {
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints:batch", strings.NewReader(body))
|
|
req = authContext(req)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
var resp batchResponse
|
|
json.NewDecoder(rec.Body).Decode(&resp)
|
|
return resp
|
|
}
|
|
|
|
r1 := sendBatch()
|
|
r2 := sendBatch()
|
|
|
|
if len(r1.AcceptedIDs) != 5 {
|
|
t.Errorf("first batch: expected 5 accepted, got %d", len(r1.AcceptedIDs))
|
|
}
|
|
if len(r2.AcceptedIDs) != 5 {
|
|
t.Errorf("second batch (idempotent): expected 5 accepted, got %d", len(r2.AcceptedIDs))
|
|
}
|
|
// No duplicates stored
|
|
if len(store.stored) != 5 {
|
|
t.Errorf("expected 5 unique stored trackpoints, got %d", len(store.stored))
|
|
}
|
|
// accepted_ids must be identical for both calls
|
|
for i := range r1.AcceptedIDs {
|
|
if r1.AcceptedIDs[i] != r2.AcceptedIDs[i] {
|
|
t.Errorf("accepted_ids differ at index %d: %q vs %q", i, r1.AcceptedIDs[i], r2.AcceptedIDs[i])
|
|
}
|
|
}
|
|
}
|