Add Go backend unit and handler tests; wire test step into CI
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:
Christoph K.
2026-04-07 19:07:02 +02:00
parent d1649ddfce
commit 034d16e059
8 changed files with 893 additions and 6 deletions

View File

@@ -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 {

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

View File

@@ -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")

View File

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

View File

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

View File

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

View File

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