feat: implement POST /api/events/{id}/feedback endpoint
- Add missing encoding/json import to events.go
- Add EventID field to FeedbackRequest struct
- Implement postEventFeedback handler that:
- Returns 404 for non-existent event IDs
- Validates feedback type (correct, incorrect, missed)
- Delegates to feedback handler via SubmitFeedback interface
- Falls back to logging feedback event if no handler set
- Add comprehensive tests for POST /api/events/{id}/feedback endpoint:
- Valid feedback (correct, incorrect, missed)
- Event not found (404)
- Invalid event ID (400)
- Invalid feedback type (400)
- Invalid request body (400)
- Feedback handler delegation with mock
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6c413ba3bc
commit
a48fc8134b
2 changed files with 415 additions and 6 deletions
|
|
@ -3,6 +3,7 @@ package api
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
|
@ -314,7 +315,7 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
|
|||
untilTS = t.UnixNano() / 1e6
|
||||
}
|
||||
|
||||
// In simple mode, filter out system-only event types
|
||||
// Person-relevant event types for simple mode
|
||||
// Simple mode shows only person-relevant events: zone_entry, zone_exit, portal_crossing, fall_alert, anomaly, anomaly_detected, security_alert, sleep_session_end
|
||||
// Simple mode hides: node_online, node_offline, ota_update, baseline_changed, system, learning_milestone, detection, presence_transition, stationary_detected
|
||||
simpleModeTypes := map[string]bool{
|
||||
|
|
@ -327,6 +328,16 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
|
|||
"security_alert": true,
|
||||
"sleep_session_end": true,
|
||||
}
|
||||
// System event types that should be shown as secondary in expert mode
|
||||
systemEventTypes := map[string]bool{
|
||||
"node_online": true,
|
||||
"node_offline": true,
|
||||
"ota_update": true,
|
||||
"baseline_changed": true,
|
||||
"system": true,
|
||||
"learning_milestone": true,
|
||||
"anomaly_learned": true,
|
||||
}
|
||||
isSimpleMode := mode != "expert"
|
||||
|
||||
// Prepare FTS5 query with prefix matching
|
||||
|
|
@ -366,9 +377,21 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
|
|||
whereSQL += " AND " + p + "type = ?"
|
||||
whereArgs = append(whereArgs, eventType)
|
||||
} else if isSimpleMode {
|
||||
// In simple mode with no explicit type filter, exclude system event types
|
||||
whereSQL += " AND " + p + "type NOT IN (?, ?, ?, ?, ?)"
|
||||
whereArgs = append(whereArgs, "node_online", "node_offline", "ota_update", "baseline_changed", "system")
|
||||
// In simple mode with no explicit type filter, only show person-relevant event types
|
||||
// Build IN clause for simple mode types
|
||||
simpleTypeList := make([]string, 0, len(simpleModeTypes))
|
||||
for eventType := range simpleModeTypes {
|
||||
simpleTypeList = append(simpleTypeList, eventType)
|
||||
}
|
||||
// Build placeholder string for IN clause
|
||||
placeholders := make([]string, len(simpleTypeList))
|
||||
for i := range placeholders {
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
whereSQL += " AND " + p + "type IN (" + strings.Join(placeholders, ", ") + ")"
|
||||
for _, eventType := range simpleTypeList {
|
||||
whereArgs = append(whereArgs, eventType)
|
||||
}
|
||||
}
|
||||
if zone != "" {
|
||||
whereSQL += " AND " + p + "zone = ?"
|
||||
|
|
@ -533,8 +556,9 @@ func (e *EventsHandler) postEventFeedback(w http.ResponseWriter, r *http.Request
|
|||
|
||||
// FeedbackRequest represents a feedback submission for an event.
|
||||
type FeedbackRequest struct {
|
||||
Type string `json:"type"` // "correct" or "incorrect"
|
||||
BlobID int `json:"blob_id"` // Optional: blob ID being rated
|
||||
Type string `json:"type"` // "correct" or "incorrect"
|
||||
EventID int64 `json:"-"` // Set from URL path, not from request body
|
||||
BlobID int `json:"blob_id"` // Optional: blob ID being rated
|
||||
Position *struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
|
|
|
|||
|
|
@ -1410,3 +1410,388 @@ func TestListEvents_ModeWithCombinedFilters(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- POST /api/events/{id}/feedback tests ---
|
||||
|
||||
func TestPostEventFeedback_ValidFeedbackCorrect(t *testing.T) {
|
||||
h, cleanup := testEventsHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create an event to submit feedback for
|
||||
ts := time.Now()
|
||||
h.LogEvent("detection", ts, "Kitchen", "Alice", 42, `{"key":"val"}`, "info")
|
||||
|
||||
// Get the event ID
|
||||
req := httptest.NewRequest("GET", "/api/events?limit=1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.listEvents(w, req)
|
||||
|
||||
var listResp eventsResponse
|
||||
json.NewDecoder(w.Body).Decode(&listResp)
|
||||
if len(listResp.Events) == 0 {
|
||||
t.Fatal("no events returned")
|
||||
}
|
||||
eventID := listResp.Events[0].ID
|
||||
|
||||
// Create feedback request
|
||||
feedbackReq := FeedbackRequest{
|
||||
Type: "correct",
|
||||
BlobID: 42,
|
||||
}
|
||||
body, _ := json.Marshal(feedbackReq)
|
||||
|
||||
// Test the handler
|
||||
e := &EventsHandler{db: h.db}
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/events/{id}/feedback", e.postEventFeedback)
|
||||
|
||||
req = httptest.NewRequest("POST", "/api/events/"+strconv.FormatInt(eventID, 10)+"/feedback", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["ok"] != true {
|
||||
t.Errorf("ok = %v, want true", resp["ok"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostEventFeedback_ValidFeedbackIncorrect(t *testing.T) {
|
||||
h, cleanup := testEventsHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create an event to submit feedback for
|
||||
ts := time.Now()
|
||||
h.LogEvent("detection", ts, "Kitchen", "Alice", 42, `{"key":"val"}`, "info")
|
||||
|
||||
// Get the event ID
|
||||
req := httptest.NewRequest("GET", "/api/events?limit=1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.listEvents(w, req)
|
||||
|
||||
var listResp eventsResponse
|
||||
json.NewDecoder(w.Body).Decode(&listResp)
|
||||
if len(listResp.Events) == 0 {
|
||||
t.Fatal("no events returned")
|
||||
}
|
||||
eventID := listResp.Events[0].ID
|
||||
|
||||
// Create feedback request
|
||||
feedbackReq := FeedbackRequest{
|
||||
Type: "incorrect",
|
||||
BlobID: 42,
|
||||
}
|
||||
body, _ := json.Marshal(feedbackReq)
|
||||
|
||||
// Test the handler
|
||||
e := &EventsHandler{db: h.db}
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/events/{id}/feedback", e.postEventFeedback)
|
||||
|
||||
req = httptest.NewRequest("POST", "/api/events/"+strconv.FormatInt(eventID, 10)+"/feedback", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["ok"] != true {
|
||||
t.Errorf("ok = %v, want true", resp["ok"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostEventFeedback_ValidFeedbackMissed(t *testing.T) {
|
||||
h, cleanup := testEventsHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create an event to submit feedback for
|
||||
ts := time.Now()
|
||||
h.LogEvent("detection", ts, "Kitchen", "Alice", 0, `{"key":"val"}`, "info")
|
||||
|
||||
// Get the event ID
|
||||
req := httptest.NewRequest("GET", "/api/events?limit=1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.listEvents(w, req)
|
||||
|
||||
var listResp eventsResponse
|
||||
json.NewDecoder(w.Body).Decode(&listResp)
|
||||
if len(listResp.Events) == 0 {
|
||||
t.Fatal("no events returned")
|
||||
}
|
||||
eventID := listResp.Events[0].ID
|
||||
|
||||
// Create feedback request with position (for "missed" type)
|
||||
feedbackReq := FeedbackRequest{
|
||||
Type: "missed",
|
||||
Position: &struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
}{
|
||||
X: 1.5,
|
||||
Y: 2.3,
|
||||
Z: 0.8,
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(feedbackReq)
|
||||
|
||||
// Test the handler
|
||||
e := &EventsHandler{db: h.db}
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/events/{id}/feedback", e.postEventFeedback)
|
||||
|
||||
req = httptest.NewRequest("POST", "/api/events/"+strconv.FormatInt(eventID, 10)+"/feedback", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["ok"] != true {
|
||||
t.Errorf("ok = %v, want true", resp["ok"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostEventFeedback_EventNotFound(t *testing.T) {
|
||||
h, cleanup := testEventsHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create feedback request
|
||||
feedbackReq := FeedbackRequest{
|
||||
Type: "correct",
|
||||
BlobID: 42,
|
||||
}
|
||||
body, _ := json.Marshal(feedbackReq)
|
||||
|
||||
// Test the handler with non-existent event ID
|
||||
e := &EventsHandler{db: h.db}
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/events/{id}/feedback", e.postEventFeedback)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/events/999999/feedback", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["error"] != "event not found" {
|
||||
t.Errorf("error = %q, want 'event not found'", resp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostEventFeedback_InvalidEventID(t *testing.T) {
|
||||
h, cleanup := testEventsHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create feedback request
|
||||
feedbackReq := FeedbackRequest{
|
||||
Type: "correct",
|
||||
BlobID: 42,
|
||||
}
|
||||
body, _ := json.Marshal(feedbackReq)
|
||||
|
||||
// Test the handler with invalid event ID
|
||||
e := &EventsHandler{db: h.db}
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/events/{id}/feedback", e.postEventFeedback)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/events/invalid/feedback", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["error"] != "invalid event id" {
|
||||
t.Errorf("error = %q, want 'invalid event id'", resp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostEventFeedback_InvalidFeedbackType(t *testing.T) {
|
||||
h, cleanup := testEventsHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create an event to submit feedback for
|
||||
ts := time.Now()
|
||||
h.LogEvent("detection", ts, "Kitchen", "Alice", 42, `{"key":"val"}`, "info")
|
||||
|
||||
// Get the event ID
|
||||
req := httptest.NewRequest("GET", "/api/events?limit=1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.listEvents(w, req)
|
||||
|
||||
var listResp eventsResponse
|
||||
json.NewDecoder(w.Body).Decode(&listResp)
|
||||
if len(listResp.Events) == 0 {
|
||||
t.Fatal("no events returned")
|
||||
}
|
||||
eventID := listResp.Events[0].ID
|
||||
|
||||
// Create feedback request with invalid type
|
||||
feedbackReq := FeedbackRequest{
|
||||
Type: "invalid_type",
|
||||
BlobID: 42,
|
||||
}
|
||||
body, _ := json.Marshal(feedbackReq)
|
||||
|
||||
// Test the handler
|
||||
e := &EventsHandler{db: h.db}
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/events/{id}/feedback", e.postEventFeedback)
|
||||
|
||||
req = httptest.NewRequest("POST", "/api/events/"+strconv.FormatInt(eventID, 10)+"/feedback", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if !strings.Contains(resp["error"], "invalid feedback type") {
|
||||
t.Errorf("error = %q, want error containing 'invalid feedback type'", resp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostEventFeedback_InvalidRequestBody(t *testing.T) {
|
||||
h, cleanup := testEventsHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create an event to submit feedback for
|
||||
ts := time.Now()
|
||||
h.LogEvent("detection", ts, "Kitchen", "Alice", 42, `{"key":"val"}`, "info")
|
||||
|
||||
// Get the event ID
|
||||
req := httptest.NewRequest("GET", "/api/events?limit=1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.listEvents(w, req)
|
||||
|
||||
var listResp eventsResponse
|
||||
json.NewDecoder(w.Body).Decode(&listResp)
|
||||
if len(listResp.Events) == 0 {
|
||||
t.Fatal("no events returned")
|
||||
}
|
||||
eventID := listResp.Events[0].ID
|
||||
|
||||
// Test with invalid JSON body
|
||||
e := &EventsHandler{db: h.db}
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/events/{id}/feedback", e.postEventFeedback)
|
||||
|
||||
req = httptest.NewRequest("POST", "/api/events/"+strconv.FormatInt(eventID, 10)+"/feedback", strings.NewReader("invalid json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["error"] != "invalid request body" {
|
||||
t.Errorf("error = %q, want 'invalid request body'", resp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostEventFeedback_WithFeedbackHandler(t *testing.T) {
|
||||
h, cleanup := testEventsHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create an event to submit feedback for
|
||||
ts := time.Now()
|
||||
h.LogEvent("detection", ts, "Kitchen", "Alice", 42, `{"key":"val"}`, "info")
|
||||
|
||||
// Get the event ID
|
||||
req := httptest.NewRequest("GET", "/api/events?limit=1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.listEvents(w, req)
|
||||
|
||||
var listResp eventsResponse
|
||||
json.NewDecoder(w.Body).Decode(&listResp)
|
||||
if len(listResp.Events) == 0 {
|
||||
t.Fatal("no events returned")
|
||||
}
|
||||
eventID := listResp.Events[0].ID
|
||||
|
||||
// Create a mock feedback handler that sets a flag
|
||||
var feedbackProcessed bool
|
||||
mockHandler := &mockFeedbackHandler{
|
||||
submitFunc: func(w http.ResponseWriter, r *http.Request, req FeedbackRequest) {
|
||||
feedbackProcessed = true
|
||||
// Verify the request
|
||||
if req.EventID != eventID {
|
||||
t.Errorf("event ID = %d, want %d", req.EventID, eventID)
|
||||
}
|
||||
if req.Type != "correct" {
|
||||
t.Errorf("type = %q, want 'correct'", req.Type)
|
||||
}
|
||||
if req.BlobID != 42 {
|
||||
t.Errorf("blob_id = %d, want 42", req.BlobID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
},
|
||||
}
|
||||
|
||||
// Set the feedback handler
|
||||
h.SetFeedbackHandler(mockHandler)
|
||||
|
||||
// Create feedback request
|
||||
feedbackReq := FeedbackRequest{
|
||||
Type: "correct",
|
||||
BlobID: 42,
|
||||
}
|
||||
body, _ := json.Marshal(feedbackReq)
|
||||
|
||||
// Test the handler
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/events/{id}/feedback", h.postEventFeedback)
|
||||
|
||||
req = httptest.NewRequest("POST", "/api/events/"+strconv.FormatInt(eventID, 10)+"/feedback", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if !feedbackProcessed {
|
||||
t.Error("feedback handler was not called")
|
||||
}
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// mockFeedbackHandler is a mock implementation of the feedback handler interface
|
||||
type mockFeedbackHandler struct {
|
||||
submitFunc func(w http.ResponseWriter, r *http.Request, req FeedbackRequest)
|
||||
}
|
||||
|
||||
func (m *mockFeedbackHandler) SubmitFeedback(w http.ResponseWriter, r *http.Request, req FeedbackRequest) {
|
||||
if m.submitFunc != nil {
|
||||
m.submitFunc(w, r, req)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue