Add Go backend unit and handler tests; wire test step into CI
Some checks failed
Deploy to NAS / deploy (push) Failing after 14s
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>
This commit is contained in:
380
backend/internal/api/ingest_test.go
Normal file
380
backend/internal/api/ingest_test.go
Normal file
@@ -0,0 +1,380 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user