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:
@@ -23,6 +23,9 @@ jobs:
|
|||||||
- name: Write .env
|
- name: Write .env
|
||||||
run: printf 'DB_PASSWORD=%s\n' '${{ secrets.DB_PASSWORD }}' > ${{ vars.DEPLOY_DIR }}/.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
|
- name: Build & Deploy
|
||||||
run: docker compose -f ${{ vars.DEPLOY_DIR }}/docker-compose.yml up --build -d
|
run: docker compose -f ${{ vars.DEPLOY_DIR }}/docker-compose.yml up --build -d
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import (
|
|||||||
"github.com/jacek/pamietnik/backend/internal/domain"
|
"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 {
|
type trackpointInput struct {
|
||||||
EventID string `json:"event_id"`
|
EventID string `json:"event_id"`
|
||||||
DeviceID string `json:"device_id"`
|
DeviceID string `json:"device_id"`
|
||||||
@@ -56,7 +59,7 @@ type batchResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleSingleTrackpoint handles POST /v1/trackpoints
|
// 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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var input trackpointInput
|
var input trackpointInput
|
||||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
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
|
// 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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var inputs []trackpointInput
|
var inputs []trackpointInput
|
||||||
if err := json.NewDecoder(r.Body).Decode(&inputs); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&inputs); err != nil {
|
||||||
|
|||||||
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// HandleListDays handles GET /v1/days?from=YYYY-MM-DD&to=YYYY-MM-DD
|
// 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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := userIDFromContext(r.Context())
|
userID := userIDFromContext(r.Context())
|
||||||
from := r.URL.Query().Get("from")
|
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
|
// 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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := userIDFromContext(r.Context())
|
userID := userIDFromContext(r.Context())
|
||||||
date := r.URL.Query().Get("date")
|
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
|
// 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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := userIDFromContext(r.Context())
|
userID := userIDFromContext(r.Context())
|
||||||
date := r.URL.Query().Get("date")
|
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
|
// 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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := userIDFromContext(r.Context())
|
userID := userIDFromContext(r.Context())
|
||||||
date := r.URL.Query().Get("date")
|
date := r.URL.Query().Get("date")
|
||||||
|
|||||||
318
backend/internal/api/query_test.go
Normal file
318
backend/internal/api/query_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
backend/internal/auth/auth_test.go
Normal file
65
backend/internal/auth/auth_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/internal/db/interfaces.go
Normal file
25
backend/internal/db/interfaces.go
Normal 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)
|
||||||
|
}
|
||||||
93
backend/internal/db/trackpoints_test.go
Normal file
93
backend/internal/db/trackpoints_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user