Files
pamietnik/backend/internal/api/ingest_test.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

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