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]) } } }