Files
event-driven-shoppinglist/api_test.go
2025-09-29 12:03:28 +02:00

628 lines
16 KiB
Go

package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// MockSimpleEventStore for API testing
type MockAPIEventStore struct {
events []Event
items map[string]map[string]interface{}
nextSeq int
}
func NewMockAPIEventStore() *MockAPIEventStore {
return &MockAPIEventStore{
events: []Event{},
items: make(map[string]map[string]interface{}),
nextSeq: 1,
}
}
func (m *MockAPIEventStore) GetLatestEvent() (*Event, error) {
if len(m.events) == 0 {
return nil, nil
}
return &m.events[len(m.events)-1], nil
}
func (m *MockAPIEventStore) ProcessEvent(incomingEvent *Event) (*Event, error) {
event := &Event{
Seq: m.nextSeq,
Hash: "mock_hash_" + string(rune(m.nextSeq+'0')),
ItemID: incomingEvent.ItemID,
EventID: "mock_event_" + string(rune(m.nextSeq+'0')),
Collection: incomingEvent.Collection,
Operation: incomingEvent.Operation,
Path: incomingEvent.Path,
Value: incomingEvent.Value,
From: incomingEvent.From,
Timestamp: time.Now(),
}
m.events = append(m.events, *event)
m.nextSeq++
// Apply operation to items for testing
itemKey := incomingEvent.Collection + ":" + incomingEvent.ItemID
if m.items[itemKey] == nil {
m.items[itemKey] = map[string]interface{}{"id": incomingEvent.ItemID}
}
// Simple mock application of JSON Patch operations
switch incomingEvent.Operation {
case "add", "replace":
field := incomingEvent.Path[1:] // remove leading "/"
m.items[itemKey][field] = incomingEvent.Value
case "remove":
field := incomingEvent.Path[1:]
delete(m.items[itemKey], field)
}
return event, nil
}
func (m *MockAPIEventStore) GetEventsSince(seq int) ([]Event, error) {
var events []Event
for _, event := range m.events {
if event.Seq > seq {
events = append(events, event)
}
}
return events, nil
}
func (m *MockAPIEventStore) GetAllItems(collection string) ([]map[string]interface{}, error) {
var items []map[string]interface{}
for key, item := range m.items {
if len(key) >= len(collection) && key[:len(collection)] == collection {
// Skip soft-deleted items
if deletedAt, exists := item["deleted_at"]; !exists || deletedAt == "" {
items = append(items, item)
}
}
}
return items, nil
}
func (m *MockAPIEventStore) ValidateSync(seq int, hash string) (bool, error) {
if len(m.events) == 0 {
return seq == 0 && hash == "", nil
}
latest := m.events[len(m.events)-1]
return latest.Seq == seq && latest.Hash == hash, nil
}
func createTestRouter(eventStore *MockAPIEventStore) *http.ServeMux {
mux := http.NewServeMux()
// PATCH endpoint for single operations
mux.HandleFunc("PATCH /api/collections/{collection}/items/{itemId}", func(w http.ResponseWriter, r *http.Request) {
collection := r.PathValue("collection")
itemID := r.PathValue("itemId")
if collection == "" || itemID == "" {
http.Error(w, "Collection and itemId are required", http.StatusBadRequest)
return
}
var operation struct {
Op string `json:"op"`
Path string `json:"path"`
Value string `json:"value"`
From string `json:"from"`
}
if err := json.NewDecoder(r.Body).Decode(&operation); err != nil {
http.Error(w, "Failed to parse operation data", http.StatusBadRequest)
return
}
incomingEvent := &Event{
ItemID: itemID,
Collection: collection,
Operation: operation.Op,
Path: operation.Path,
Value: operation.Value,
From: operation.From,
}
processedEvent, err := eventStore.ProcessEvent(incomingEvent)
if err != nil {
http.Error(w, "Failed to process event", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(processedEvent)
})
// POST endpoint for legacy events
mux.HandleFunc("POST /api/events", func(w http.ResponseWriter, r *http.Request) {
var incomingEvent Event
if err := json.NewDecoder(r.Body).Decode(&incomingEvent); err != nil {
http.Error(w, "Failed to parse event data", http.StatusBadRequest)
return
}
if incomingEvent.ItemID == "" || incomingEvent.Collection == "" || incomingEvent.Operation == "" {
http.Error(w, "Missing required fields: item_id, collection, operation", http.StatusBadRequest)
return
}
processedEvent, err := eventStore.ProcessEvent(&incomingEvent)
if err != nil {
http.Error(w, "Failed to process event", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(processedEvent)
})
// Sync endpoint
mux.HandleFunc("POST /api/sync", func(w http.ResponseWriter, r *http.Request) {
var syncReq SyncRequest
if err := json.NewDecoder(r.Body).Decode(&syncReq); err != nil {
http.Error(w, "Failed to parse sync request", http.StatusBadRequest)
return
}
isValid, err := eventStore.ValidateSync(syncReq.LastSeq, syncReq.LastHash)
if err != nil {
http.Error(w, "Failed to validate sync", http.StatusInternalServerError)
return
}
events, err := eventStore.GetEventsSince(syncReq.LastSeq)
if err != nil {
http.Error(w, "Failed to get events", http.StatusInternalServerError)
return
}
latestEvent, _ := eventStore.GetLatestEvent()
response := SyncResponse{
Events: events,
FullSync: !isValid,
}
if latestEvent != nil {
response.CurrentSeq = latestEvent.Seq
response.CurrentHash = latestEvent.Hash
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
})
// Get items endpoint
mux.HandleFunc("GET /api/items/{collection}", func(w http.ResponseWriter, r *http.Request) {
collection := r.PathValue("collection")
items, err := eventStore.GetAllItems(collection)
if err != nil {
http.Error(w, "Failed to get items", http.StatusInternalServerError)
return
}
response := map[string]interface{}{"items": items}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
})
// Health endpoint
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
response := map[string]interface{}{
"message": "API is healthy.",
"code": 200,
"data": map[string]interface{}{},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
})
// State endpoint
mux.HandleFunc("GET /api/state", func(w http.ResponseWriter, r *http.Request) {
latestEvent, _ := eventStore.GetLatestEvent()
response := map[string]interface{}{
"seq": 0,
"hash": "",
}
if latestEvent != nil {
response["seq"] = latestEvent.Seq
response["hash"] = latestEvent.Hash
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
})
return mux
}
func TestAPI_PatchEndpoint_CreateItem(t *testing.T) {
mockStore := NewMockAPIEventStore()
router := createTestRouter(mockStore)
operation := map[string]string{
"op": "add",
"path": "/content",
"value": "test item content",
}
body, _ := json.Marshal(operation)
req := httptest.NewRequest("PATCH", "/api/collections/shopping_items/items/test123", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response Event
err := json.NewDecoder(w.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, "test123", response.ItemID)
assert.Equal(t, "shopping_items", response.Collection)
assert.Equal(t, "add", response.Operation)
assert.Equal(t, "/content", response.Path)
assert.Equal(t, "test item content", response.Value)
assert.Equal(t, 1, response.Seq)
}
func TestAPI_PatchEndpoint_UpdateItem(t *testing.T) {
mockStore := NewMockAPIEventStore()
router := createTestRouter(mockStore)
// Create item first
createOp := map[string]string{
"op": "add",
"path": "/content",
"value": "original content",
}
body, _ := json.Marshal(createOp)
req := httptest.NewRequest("PATCH", "/api/collections/shopping_items/items/test123", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Update item
updateOp := map[string]string{
"op": "replace",
"path": "/content",
"value": "updated content",
}
body, _ = json.Marshal(updateOp)
req = httptest.NewRequest("PATCH", "/api/collections/shopping_items/items/test123", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response Event
err := json.NewDecoder(w.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, "test123", response.ItemID)
assert.Equal(t, "replace", response.Operation)
assert.Equal(t, "/content", response.Path)
assert.Equal(t, "updated content", response.Value)
assert.Equal(t, 2, response.Seq)
}
func TestAPI_PatchEndpoint_InvalidData(t *testing.T) {
mockStore := NewMockAPIEventStore()
router := createTestRouter(mockStore)
req := httptest.NewRequest("PATCH", "/api/collections/shopping_items/items/test123", bytes.NewReader([]byte("invalid json")))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestAPI_PostEndpoint_LegacyEvent(t *testing.T) {
mockStore := NewMockAPIEventStore()
router := createTestRouter(mockStore)
event := Event{
ItemID: "test123",
Collection: "shopping_items",
Operation: "add",
Path: "/content",
Value: "test content",
}
body, _ := json.Marshal(event)
req := httptest.NewRequest("POST", "/api/events", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var response Event
err := json.NewDecoder(w.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, "test123", response.ItemID)
assert.Equal(t, "add", response.Operation)
}
func TestAPI_PostEndpoint_MissingFields(t *testing.T) {
tests := []struct {
name string
event Event
}{
{
name: "missing item_id",
event: Event{
Collection: "shopping_items",
Operation: "add",
Path: "/content",
Value: "test",
},
},
{
name: "missing collection",
event: Event{
ItemID: "test123",
Operation: "add",
Path: "/content",
Value: "test",
},
},
{
name: "missing operation",
event: Event{
ItemID: "test123",
Collection: "shopping_items",
Path: "/content",
Value: "test",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockStore := NewMockAPIEventStore()
router := createTestRouter(mockStore)
body, _ := json.Marshal(tt.event)
req := httptest.NewRequest("POST", "/api/events", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
}
}
func TestAPI_SyncEndpoint_FullSync(t *testing.T) {
mockStore := NewMockAPIEventStore()
router := createTestRouter(mockStore)
// Add some events first
event1 := &Event{
ItemID: "item1",
Collection: "shopping_items",
Operation: "add",
Path: "/content",
Value: "Item 1",
}
mockStore.ProcessEvent(event1)
event2 := &Event{
ItemID: "item2",
Collection: "shopping_items",
Operation: "add",
Path: "/content",
Value: "Item 2",
}
mockStore.ProcessEvent(event2)
// Request sync from beginning
syncReq := SyncRequest{
LastSeq: 0,
LastHash: "",
}
body, _ := json.Marshal(syncReq)
req := httptest.NewRequest("POST", "/api/sync", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response SyncResponse
err := json.NewDecoder(w.Body).Decode(&response)
require.NoError(t, err)
assert.Len(t, response.Events, 2)
assert.Equal(t, 2, response.CurrentSeq)
}
func TestAPI_SyncEndpoint_IncrementalSync(t *testing.T) {
mockStore := NewMockAPIEventStore()
router := createTestRouter(mockStore)
// Add some events first
event1 := &Event{
ItemID: "item1",
Collection: "shopping_items",
Operation: "add",
Path: "/content",
Value: "Item 1",
}
mockStore.ProcessEvent(event1)
event2 := &Event{
ItemID: "item2",
Collection: "shopping_items",
Operation: "add",
Path: "/content",
Value: "Item 2",
}
mockStore.ProcessEvent(event2)
// Request sync from seq 1
syncReq := SyncRequest{
LastSeq: 1,
LastHash: "mock_hash_1",
}
body, _ := json.Marshal(syncReq)
req := httptest.NewRequest("POST", "/api/sync", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response SyncResponse
err := json.NewDecoder(w.Body).Decode(&response)
require.NoError(t, err)
assert.Len(t, response.Events, 1) // Only event 2
assert.Equal(t, 2, response.CurrentSeq)
}
func TestAPI_GetItemsEndpoint(t *testing.T) {
mockStore := NewMockAPIEventStore()
router := createTestRouter(mockStore)
// Add some items
event1 := &Event{
ItemID: "item1",
Collection: "shopping_items",
Operation: "add",
Path: "/content",
Value: "Item 1",
}
mockStore.ProcessEvent(event1)
event2 := &Event{
ItemID: "item2",
Collection: "shopping_items",
Operation: "add",
Path: "/content",
Value: "Item 2",
}
mockStore.ProcessEvent(event2)
req := httptest.NewRequest("GET", "/api/items/shopping_items", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.NewDecoder(w.Body).Decode(&response)
require.NoError(t, err)
items, ok := response["items"].([]interface{})
require.True(t, ok)
assert.Len(t, items, 2)
}
func TestAPI_GetStateEndpoint(t *testing.T) {
mockStore := NewMockAPIEventStore()
router := createTestRouter(mockStore)
// Test empty state
req := httptest.NewRequest("GET", "/api/state", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.NewDecoder(w.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, float64(0), response["seq"])
assert.Equal(t, "", response["hash"])
// Add an event
event := &Event{
ItemID: "item1",
Collection: "shopping_items",
Operation: "add",
Path: "/content",
Value: "Item 1",
}
mockStore.ProcessEvent(event)
// Test state with event
req = httptest.NewRequest("GET", "/api/state", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
err = json.NewDecoder(w.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, float64(1), response["seq"])
assert.NotEmpty(t, response["hash"])
}
func TestAPI_PathValues(t *testing.T) {
mockStore := NewMockAPIEventStore()
router := createTestRouter(mockStore)
tests := []struct {
name string
url string
method string
expectedStatus int
}{
{
name: "valid patch path",
url: "/api/collections/shopping_items/items/test123",
method: "PATCH",
expectedStatus: http.StatusOK, // Valid operation should succeed
},
{
name: "valid get items path",
url: "/api/items/shopping_items",
method: "GET",
expectedStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var req *http.Request
if tt.method == "PATCH" {
req = httptest.NewRequest(tt.method, tt.url, bytes.NewReader([]byte("{}")))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(tt.method, tt.url, nil)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.expectedStatus, w.Code)
})
}
}