From 034d16e059588524561fba56d86731bb2037a1a8 Mon Sep 17 00:00:00 2001 From: "Christoph K." Date: Tue, 7 Apr 2026 19:07:02 +0200 Subject: [PATCH] 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 --- .gitea/workflows/deploy.yml | 3 + backend/internal/api/ingest.go | 7 +- backend/internal/api/ingest_test.go | 380 ++++++++++++++++++++++++ backend/internal/api/query.go | 8 +- backend/internal/api/query_test.go | 318 ++++++++++++++++++++ backend/internal/auth/auth_test.go | 65 ++++ backend/internal/db/interfaces.go | 25 ++ backend/internal/db/trackpoints_test.go | 93 ++++++ 8 files changed, 893 insertions(+), 6 deletions(-) create mode 100644 backend/internal/api/ingest_test.go create mode 100644 backend/internal/api/query_test.go create mode 100644 backend/internal/auth/auth_test.go create mode 100644 backend/internal/db/interfaces.go create mode 100644 backend/internal/db/trackpoints_test.go diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 606ab14..f87b34c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -23,6 +23,9 @@ jobs: - name: Write .env run: printf 'DB_PASSWORD=%s\n' '${{ secrets.DB_PASSWORD }}' > ${{ vars.DEPLOY_DIR }}/.env + - name: Test + run: cd ${{ vars.DEPLOY_DIR }}/backend && go test ./... + - name: Build & Deploy run: docker compose -f ${{ vars.DEPLOY_DIR }}/docker-compose.yml up --build -d diff --git a/backend/internal/api/ingest.go b/backend/internal/api/ingest.go index 37db2f4..ee01cc2 100644 --- a/backend/internal/api/ingest.go +++ b/backend/internal/api/ingest.go @@ -9,6 +9,9 @@ import ( "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"` @@ -56,7 +59,7 @@ type batchResponse struct { } // HandleSingleTrackpoint handles POST /v1/trackpoints -func HandleSingleTrackpoint(store *db.TrackpointStore) http.HandlerFunc { +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 { @@ -86,7 +89,7 @@ func HandleSingleTrackpoint(store *db.TrackpointStore) http.HandlerFunc { } // HandleBatchTrackpoints handles POST /v1/trackpoints:batch -func HandleBatchTrackpoints(store *db.TrackpointStore) http.HandlerFunc { +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 { diff --git a/backend/internal/api/ingest_test.go b/backend/internal/api/ingest_test.go new file mode 100644 index 0000000..1b49a6a --- /dev/null +++ b/backend/internal/api/ingest_test.go @@ -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]) + } + } +} diff --git a/backend/internal/api/query.go b/backend/internal/api/query.go index dc0b8d6..84b9c59 100644 --- a/backend/internal/api/query.go +++ b/backend/internal/api/query.go @@ -9,7 +9,7 @@ import ( ) // HandleListDays handles GET /v1/days?from=YYYY-MM-DD&to=YYYY-MM-DD -func HandleListDays(store *db.TrackpointStore) http.HandlerFunc { +func HandleListDays(store db.TrackpointStorer) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) from := r.URL.Query().Get("from") @@ -33,7 +33,7 @@ func HandleListDays(store *db.TrackpointStore) http.HandlerFunc { } // HandleListTrackpoints handles GET /v1/trackpoints?date=YYYY-MM-DD -func HandleListTrackpoints(store *db.TrackpointStore) http.HandlerFunc { +func HandleListTrackpoints(store db.TrackpointStorer) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) date := r.URL.Query().Get("date") @@ -56,7 +56,7 @@ func HandleListTrackpoints(store *db.TrackpointStore) http.HandlerFunc { } // HandleListStops handles GET /v1/stops?date=YYYY-MM-DD -func HandleListStops(store *db.StopStore) http.HandlerFunc { +func HandleListStops(store db.StopStorer) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) date := r.URL.Query().Get("date") @@ -79,7 +79,7 @@ func HandleListStops(store *db.StopStore) http.HandlerFunc { } // HandleListSuggestions handles GET /v1/suggestions?date=YYYY-MM-DD -func HandleListSuggestions(store *db.SuggestionStore) http.HandlerFunc { +func HandleListSuggestions(store db.SuggestionStorer) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) date := r.URL.Query().Get("date") diff --git a/backend/internal/api/query_test.go b/backend/internal/api/query_test.go new file mode 100644 index 0000000..b4ea39a --- /dev/null +++ b/backend/internal/api/query_test.go @@ -0,0 +1,318 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/jacek/pamietnik/backend/internal/db" + "github.com/jacek/pamietnik/backend/internal/domain" +) + +// fakeQueryTrackpointStore implements db.TrackpointStorer for query handler tests. +type fakeQueryTrackpointStore struct { + days []domain.DaySummary + points []domain.Trackpoint + err error +} + +func (f *fakeQueryTrackpointStore) UpsertBatch(_ context.Context, _ string, _ []domain.Trackpoint) ([]string, []db.RejectedItem, error) { + return nil, nil, nil +} + +func (f *fakeQueryTrackpointStore) ListByDate(_ context.Context, _, _ string) ([]domain.Trackpoint, error) { + return f.points, f.err +} + +func (f *fakeQueryTrackpointStore) ListDays(_ context.Context, _, _, _ string) ([]domain.DaySummary, error) { + return f.days, f.err +} + +// fakeStopStore implements db.StopStorer. +type fakeStopStore struct { + stops []domain.Stop + err error +} + +func (f *fakeStopStore) ListByDate(_ context.Context, _, _ string) ([]domain.Stop, error) { + return f.stops, f.err +} + +// fakeSuggestionStore implements db.SuggestionStorer. +type fakeSuggestionStore struct { + suggestions []domain.Suggestion + err error +} + +func (f *fakeSuggestionStore) ListByDate(_ context.Context, _, _ string) ([]domain.Suggestion, error) { + return f.suggestions, f.err +} + +// --- HandleListDays --- + +func TestHandleListDays_MissingFromParam(t *testing.T) { + handler := HandleListDays(&fakeQueryTrackpointStore{}) + + req := httptest.NewRequest(http.MethodGet, "/v1/days?to=2024-06-30", nil) + req = authContext(req) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400 when 'from' missing, got %d", rec.Code) + } +} + +func TestHandleListDays_MissingToParam(t *testing.T) { + handler := HandleListDays(&fakeQueryTrackpointStore{}) + + req := httptest.NewRequest(http.MethodGet, "/v1/days?from=2024-06-01", nil) + req = authContext(req) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400 when 'to' missing, got %d", rec.Code) + } +} + +func TestHandleListDays_BothParamsMissing(t *testing.T) { + handler := HandleListDays(&fakeQueryTrackpointStore{}) + + req := httptest.NewRequest(http.MethodGet, "/v1/days", nil) + req = authContext(req) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400 when both params missing, got %d", rec.Code) + } +} + +func TestHandleListDays_EmptyResultIsArray(t *testing.T) { + handler := HandleListDays(&fakeQueryTrackpointStore{days: nil}) + + req := httptest.NewRequest(http.MethodGet, "/v1/days?from=2024-06-01&to=2024-06-30", nil) + req = authContext(req) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + // Must be a JSON array, not null + body := strings.TrimSpace(rec.Body.String()) + if !strings.HasPrefix(body, "[") { + t.Errorf("expected JSON array, got: %s", body) + } +} + +func TestHandleListDays_ReturnsDays(t *testing.T) { + ts := time.Now() + store := &fakeQueryTrackpointStore{ + days: []domain.DaySummary{ + {Date: "2024-06-01", Count: 42, FirstTS: &ts, LastTS: &ts}, + }, + } + handler := HandleListDays(store) + + req := httptest.NewRequest(http.MethodGet, "/v1/days?from=2024-06-01&to=2024-06-30", nil) + req = authContext(req) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + var days []domain.DaySummary + json.NewDecoder(rec.Body).Decode(&days) + if len(days) != 1 || days[0].Date != "2024-06-01" || days[0].Count != 42 { + t.Errorf("unexpected response: %+v", days) + } +} + +// --- HandleListTrackpoints --- + +func TestHandleListTrackpoints_MissingDateParam(t *testing.T) { + handler := HandleListTrackpoints(&fakeQueryTrackpointStore{}) + + req := httptest.NewRequest(http.MethodGet, "/v1/trackpoints", nil) + req = authContext(req) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400 when 'date' missing, got %d", rec.Code) + } +} + +func TestHandleListTrackpoints_EmptyResultIsArray(t *testing.T) { + handler := HandleListTrackpoints(&fakeQueryTrackpointStore{points: nil}) + + req := httptest.NewRequest(http.MethodGet, "/v1/trackpoints?date=2024-06-01", nil) + req = authContext(req) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + body := strings.TrimSpace(rec.Body.String()) + if !strings.HasPrefix(body, "[") { + t.Errorf("expected JSON array, got: %s", body) + } +} + +func TestHandleListTrackpoints_ReturnsPoints(t *testing.T) { + store := &fakeQueryTrackpointStore{ + points: []domain.Trackpoint{ + {EventID: "e1", DeviceID: "d1", Lat: 52.5, Lon: 13.4, Source: "gps"}, + {EventID: "e2", DeviceID: "d1", Lat: 52.6, Lon: 13.5, Source: "gps"}, + }, + } + handler := HandleListTrackpoints(store) + + req := httptest.NewRequest(http.MethodGet, "/v1/trackpoints?date=2024-06-01", nil) + req = authContext(req) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + var pts []domain.Trackpoint + json.NewDecoder(rec.Body).Decode(&pts) + if len(pts) != 2 { + t.Errorf("expected 2 trackpoints, got %d", len(pts)) + } +} + +// --- HandleListStops --- + +func TestHandleListStops_MissingDateParam(t *testing.T) { + handler := HandleListStops(&fakeStopStore{}) + + req := httptest.NewRequest(http.MethodGet, "/v1/stops", nil) + req = authContext(req) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400 when 'date' missing, got %d", rec.Code) + } +} + +func TestHandleListStops_EmptyResultIsArray(t *testing.T) { + handler := HandleListStops(&fakeStopStore{stops: nil}) + + req := httptest.NewRequest(http.MethodGet, "/v1/stops?date=2024-06-01", nil) + req = authContext(req) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + body := strings.TrimSpace(rec.Body.String()) + if !strings.HasPrefix(body, "[") { + t.Errorf("expected JSON array, got: %s", body) + } +} + +func TestHandleListStops_ReturnsStops(t *testing.T) { + now := time.Now() + store := &fakeStopStore{ + stops: []domain.Stop{ + {StopID: "stop-1", DeviceID: "d1", StartTS: now, EndTS: now, CenterLat: 52.5, CenterLon: 13.4, DurationS: 600}, + }, + } + handler := HandleListStops(store) + + req := httptest.NewRequest(http.MethodGet, "/v1/stops?date=2024-06-01", nil) + req = authContext(req) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + var stops []domain.Stop + json.NewDecoder(rec.Body).Decode(&stops) + if len(stops) != 1 || stops[0].StopID != "stop-1" { + t.Errorf("unexpected response: %+v", stops) + } +} + +// --- HandleListSuggestions --- + +func TestHandleListSuggestions_MissingDateParam(t *testing.T) { + handler := HandleListSuggestions(&fakeSuggestionStore{}) + + req := httptest.NewRequest(http.MethodGet, "/v1/suggestions", nil) + req = authContext(req) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400 when 'date' missing, got %d", rec.Code) + } +} + +func TestHandleListSuggestions_EmptyResultIsArray(t *testing.T) { + handler := HandleListSuggestions(&fakeSuggestionStore{suggestions: nil}) + + req := httptest.NewRequest(http.MethodGet, "/v1/suggestions?date=2024-06-01", nil) + req = authContext(req) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + body := strings.TrimSpace(rec.Body.String()) + if !strings.HasPrefix(body, "[") { + t.Errorf("expected JSON array, got: %s", body) + } +} + +func TestHandleListSuggestions_ReturnsSuggestions(t *testing.T) { + now := time.Now() + store := &fakeSuggestionStore{ + suggestions: []domain.Suggestion{ + {SuggestionID: "sug-1", StopID: "stop-1", Type: "highlight", Title: "Nice spot", CreatedAt: now}, + }, + } + handler := HandleListSuggestions(store) + + req := httptest.NewRequest(http.MethodGet, "/v1/suggestions?date=2024-06-01", nil) + req = authContext(req) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + var suggestions []domain.Suggestion + json.NewDecoder(rec.Body).Decode(&suggestions) + if len(suggestions) != 1 || suggestions[0].SuggestionID != "sug-1" { + t.Errorf("unexpected response: %+v", suggestions) + } +} diff --git a/backend/internal/auth/auth_test.go b/backend/internal/auth/auth_test.go new file mode 100644 index 0000000..e54cb55 --- /dev/null +++ b/backend/internal/auth/auth_test.go @@ -0,0 +1,65 @@ +package auth + +import ( + "testing" +) + +func TestHashPassword_ProducesVerifiableHash(t *testing.T) { + hash, err := HashPassword("secret") + if err != nil { + t.Fatalf("HashPassword returned error: %v", err) + } + if hash == "" { + t.Fatal("HashPassword returned empty string") + } + if !VerifyPassword("secret", hash) { + t.Error("VerifyPassword returned false for correct password") + } +} + +func TestHashPassword_TwoCallsProduceDifferentHashes(t *testing.T) { + h1, err1 := HashPassword("secret") + h2, err2 := HashPassword("secret") + if err1 != nil || err2 != nil { + t.Fatalf("HashPassword error: %v / %v", err1, err2) + } + // Different salts → different hashes + if h1 == h2 { + t.Error("expected distinct hashes for same password (due to random salt), got identical") + } +} + +func TestVerifyPassword_WrongPassword(t *testing.T) { + hash, err := HashPassword("correct") + if err != nil { + t.Fatalf("HashPassword error: %v", err) + } + if VerifyPassword("wrong", hash) { + t.Error("VerifyPassword returned true for wrong password") + } +} + +func TestVerifyPassword_EmptyPassword(t *testing.T) { + hash, err := HashPassword("notempty") + if err != nil { + t.Fatalf("HashPassword error: %v", err) + } + if VerifyPassword("", hash) { + t.Error("VerifyPassword returned true for empty password against non-empty hash") + } +} + +func TestVerifyPassword_MalformedHash(t *testing.T) { + cases := []string{ + "", + "notahash", + "$wrongalgo$aabb$ccdd", + "$argon2id$nothex$ccdd", + "$argon2id$aabb$nothex", + } + for _, h := range cases { + if VerifyPassword("secret", h) { + t.Errorf("VerifyPassword should return false for malformed hash %q", h) + } + } +} diff --git a/backend/internal/db/interfaces.go b/backend/internal/db/interfaces.go new file mode 100644 index 0000000..f155904 --- /dev/null +++ b/backend/internal/db/interfaces.go @@ -0,0 +1,25 @@ +package db + +import ( + "context" + + "github.com/jacek/pamietnik/backend/internal/domain" +) + +// TrackpointStorer is the interface consumed by HTTP handlers. +// The concrete TrackpointStore satisfies it. +type TrackpointStorer interface { + UpsertBatch(ctx context.Context, userID string, points []domain.Trackpoint) (accepted []string, rejected []RejectedItem, err error) + ListByDate(ctx context.Context, userID, date string) ([]domain.Trackpoint, error) + ListDays(ctx context.Context, userID, from, to string) ([]domain.DaySummary, error) +} + +// StopStorer is the interface consumed by HTTP handlers. +type StopStorer interface { + ListByDate(ctx context.Context, userID, date string) ([]domain.Stop, error) +} + +// SuggestionStorer is the interface consumed by HTTP handlers. +type SuggestionStorer interface { + ListByDate(ctx context.Context, userID, date string) ([]domain.Suggestion, error) +} diff --git a/backend/internal/db/trackpoints_test.go b/backend/internal/db/trackpoints_test.go new file mode 100644 index 0000000..d974931 --- /dev/null +++ b/backend/internal/db/trackpoints_test.go @@ -0,0 +1,93 @@ +package db + +import ( + "testing" + + "github.com/jacek/pamietnik/backend/internal/domain" +) + +func TestValidateTrackpoint_HappyPath(t *testing.T) { + p := domain.Trackpoint{ + EventID: "evt-1", + DeviceID: "dev-1", + Lat: 52.5, + Lon: 13.4, + Source: "gps", + } + if err := validateTrackpoint(p); err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +func TestValidateTrackpoint_MissingEventID(t *testing.T) { + p := domain.Trackpoint{ + DeviceID: "dev-1", + Lat: 52.5, + Lon: 13.4, + } + err := validateTrackpoint(p) + if err == nil { + t.Fatal("expected error for missing event_id, got nil") + } +} + +func TestValidateTrackpoint_MissingDeviceID(t *testing.T) { + p := domain.Trackpoint{ + EventID: "evt-1", + Lat: 52.5, + Lon: 13.4, + } + err := validateTrackpoint(p) + if err == nil { + t.Fatal("expected error for missing device_id, got nil") + } +} + +func TestValidateTrackpoint_LatOutOfRange(t *testing.T) { + cases := []struct { + lat float64 + lon float64 + }{ + {91, 0}, + {-91, 0}, + {0, 181}, + {0, -181}, + } + for _, c := range cases { + p := domain.Trackpoint{ + EventID: "evt-1", + DeviceID: "dev-1", + Lat: c.lat, + Lon: c.lon, + } + if err := validateTrackpoint(p); err == nil { + t.Errorf("expected error for lat=%v lon=%v, got nil", c.lat, c.lon) + } + } +} + +func TestValidateTrackpoint_InvalidSource(t *testing.T) { + p := domain.Trackpoint{ + EventID: "evt-1", + DeviceID: "dev-1", + Lat: 10, + Lon: 10, + Source: "satellite", + } + if err := validateTrackpoint(p); err == nil { + t.Fatal("expected error for invalid source, got nil") + } +} + +func TestValidateTrackpoint_EmptySourceIsAllowed(t *testing.T) { + p := domain.Trackpoint{ + EventID: "evt-1", + DeviceID: "dev-1", + Lat: 10, + Lon: 10, + Source: "", + } + if err := validateTrackpoint(p); err != nil { + t.Fatalf("empty source should be allowed, got: %v", err) + } +}