package main import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/stretchr/testify/assert" ) // MockSimpleEventStore for testing API endpoints type MockSimpleEventStore struct { events []Event items map[string]map[string]interface{} nextSeq int } func NewMockSimpleEventStore() *MockSimpleEventStore { return &MockSimpleEventStore{ events: []Event{}, items: make(map[string]map[string]interface{}), nextSeq: 1, } } func (m *MockSimpleEventStore) GetLatestEvent() (*Event, error) { if len(m.events) == 0 { return nil, nil } return &m.events[len(m.events)-1], nil } func (m *MockSimpleEventStore) ProcessEvent(incomingEvent *Event) (*Event, error) { // Simulate event processing event := &Event{ Seq: m.nextSeq, Hash: "mock_hash_" + string(rune(m.nextSeq)), ItemID: incomingEvent.ItemID, EventID: "mock_event_" + string(rune(m.nextSeq)), Collection: incomingEvent.Collection, Patches: incomingEvent.Patches, Timestamp: time.Now(), } m.events = append(m.events, *event) m.nextSeq++ // Apply patches to items key := event.Collection + ":" + event.ItemID if m.items[key] == nil { m.items[key] = map[string]interface{}{ "id": event.ItemID, "created_at": event.Timestamp, } } patcher := &JSONPatcher{} updated, err := patcher.ApplyPatches(m.items[key], event.Patches) if err != nil { return nil, err } updated["updated_at"] = event.Timestamp m.items[key] = updated return event, nil } func (m *MockSimpleEventStore) GetEventsSince(seq int) ([]Event, error) { var result []Event for _, event := range m.events { if event.Seq > seq { result = append(result, event) } } return result, nil } func (m *MockSimpleEventStore) GetAllItems(collection string) ([]map[string]interface{}, error) { var result []map[string]interface{} for key, item := range m.items { if len(key) > len(collection) && key[:len(collection)] == collection { // Check if item is not deleted if deletedAt, exists := item["deleted_at"]; !exists || deletedAt == nil { result = append(result, item) } } } return result, nil } func (m *MockSimpleEventStore) ValidateSync(clientSeq int, clientHash string) (bool, error) { if clientSeq == 0 { return false, nil } for _, event := range m.events { if event.Seq == clientSeq { return event.Hash == clientHash, nil } } return false, nil // Event not found } func setupTestRouter(eventStore *MockSimpleEventStore) http.Handler { mux := http.NewServeMux() // JSON Patch endpoint 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 patches []PatchOperation if err := json.NewDecoder(r.Body).Decode(&patches); err != nil { http.Error(w, "Failed to parse JSON Patch data", http.StatusBadRequest) return } incomingEvent := &Event{ ItemID: itemID, Collection: collection, Patches: patches, } 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) }) // Legacy POST endpoint 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 == "" || len(incomingEvent.Patches) == 0 { http.Error(w, "Missing required fields: item_id, collection, patches", 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 } var response SyncResponse if !isValid { events, err := eventStore.GetEventsSince(0) if err != nil { http.Error(w, "Failed to get events", http.StatusInternalServerError) return } response.Events = events response.FullSync = true } else { events, err := eventStore.GetEventsSince(syncReq.LastSeq) if err != nil { http.Error(w, "Failed to get events", http.StatusInternalServerError) return } response.Events = events response.FullSync = false } latestEvent, err := eventStore.GetLatestEvent() if err != nil { http.Error(w, "Failed to get latest event", http.StatusInternalServerError) return } 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") if collection == "" { http.Error(w, "Collection name required", http.StatusBadRequest) return } items, err := eventStore.GetAllItems(collection) if err != nil { http.Error(w, "Failed to get items", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "items": items, }) }) // Get state endpoint mux.HandleFunc("GET /api/state", func(w http.ResponseWriter, r *http.Request) { latestEvent, err := eventStore.GetLatestEvent() if err != nil { http.Error(w, "Failed to get latest event", http.StatusInternalServerError) return } 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) { eventStore := NewMockSimpleEventStore() router := setupTestRouter(eventStore) patches := []PatchOperation{ {Op: "add", Path: "/content", Value: "Test Item"}, {Op: "add", Path: "/priority", Value: "high"}, } body, _ := json.Marshal(patches) req := httptest.NewRequest("PATCH", "/api/collections/shopping_items/items/item1", 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) assert.NoError(t, err) assert.Equal(t, "item1", response.ItemID) assert.Equal(t, "shopping_items", response.Collection) assert.Equal(t, 1, response.Seq) assert.Len(t, response.Patches, 2) } func TestAPI_PatchEndpoint_UpdateItem(t *testing.T) { eventStore := NewMockSimpleEventStore() router := setupTestRouter(eventStore) // First create an item createPatches := []PatchOperation{ {Op: "add", Path: "/content", Value: "Original Content"}, } body, _ := json.Marshal(createPatches) req := httptest.NewRequest("PATCH", "/api/collections/shopping_items/items/item1", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) // Now update the item updatePatches := []PatchOperation{ {Op: "replace", Path: "/content", Value: "Updated Content"}, {Op: "add", Path: "/status", Value: "published"}, } body, _ = json.Marshal(updatePatches) req = httptest.NewRequest("PATCH", "/api/collections/shopping_items/items/item1", 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) assert.NoError(t, err) assert.Equal(t, "item1", response.ItemID) assert.Equal(t, 2, response.Seq) assert.Len(t, response.Patches, 2) } func TestAPI_PatchEndpoint_InvalidData(t *testing.T) { eventStore := NewMockSimpleEventStore() router := setupTestRouter(eventStore) // Test with invalid JSON req := httptest.NewRequest("PATCH", "/api/collections/shopping_items/items/item1", 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) { eventStore := NewMockSimpleEventStore() router := setupTestRouter(eventStore) event := Event{ ItemID: "item1", Collection: "shopping_items", Patches: []PatchOperation{ {Op: "add", Path: "/content", Value: "Test Item"}, }, } 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) assert.NoError(t, err) assert.Equal(t, "item1", response.ItemID) assert.Equal(t, "shopping_items", response.Collection) assert.Equal(t, 1, response.Seq) } func TestAPI_PostEndpoint_MissingFields(t *testing.T) { eventStore := NewMockSimpleEventStore() router := setupTestRouter(eventStore) tests := []struct { name string event Event }{ { name: "missing item_id", event: Event{ Collection: "shopping_items", Patches: []PatchOperation{{Op: "add", Path: "/content", Value: "test"}}, }, }, { name: "missing collection", event: Event{ ItemID: "item1", Patches: []PatchOperation{{Op: "add", Path: "/content", Value: "test"}}, }, }, { name: "missing patches", event: Event{ ItemID: "item1", Collection: "shopping_items", Patches: []PatchOperation{}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { 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) { eventStore := NewMockSimpleEventStore() router := setupTestRouter(eventStore) // Create some events first event1 := Event{ ItemID: "item1", Collection: "shopping_items", Patches: []PatchOperation{{Op: "add", Path: "/content", Value: "Item 1"}}, } eventStore.ProcessEvent(&event1) event2 := Event{ ItemID: "item2", Collection: "shopping_items", Patches: []PatchOperation{{Op: "add", Path: "/content", Value: "Item 2"}}, } eventStore.ProcessEvent(&event2) // Request full sync (client starts with seq 0) 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) assert.NoError(t, err) assert.True(t, response.FullSync) assert.Len(t, response.Events, 2) assert.Equal(t, 2, response.CurrentSeq) assert.NotEmpty(t, response.CurrentHash) } func TestAPI_SyncEndpoint_IncrementalSync(t *testing.T) { eventStore := NewMockSimpleEventStore() router := setupTestRouter(eventStore) // Create some events event1 := Event{ ItemID: "item1", Collection: "shopping_items", Patches: []PatchOperation{{Op: "add", Path: "/content", Value: "Item 1"}}, } processedEvent1, _ := eventStore.ProcessEvent(&event1) event2 := Event{ ItemID: "item2", Collection: "shopping_items", Patches: []PatchOperation{{Op: "add", Path: "/content", Value: "Item 2"}}, } eventStore.ProcessEvent(&event2) // Request incremental sync (client has first event) syncReq := SyncRequest{ LastSeq: processedEvent1.Seq, LastHash: processedEvent1.Hash, } 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) assert.NoError(t, err) assert.False(t, response.FullSync) assert.Len(t, response.Events, 1) // Only the second event assert.Equal(t, 2, response.CurrentSeq) } func TestAPI_GetItemsEndpoint(t *testing.T) { eventStore := NewMockSimpleEventStore() router := setupTestRouter(eventStore) // Create some items event1 := Event{ ItemID: "item1", Collection: "shopping_items", Patches: []PatchOperation{{Op: "add", Path: "/content", Value: "Item 1"}}, } eventStore.ProcessEvent(&event1) event2 := Event{ ItemID: "item2", Collection: "shopping_items", Patches: []PatchOperation{{Op: "add", Path: "/content", Value: "Item 2"}}, } eventStore.ProcessEvent(&event2) // Delete one item event3 := Event{ ItemID: "item2", Collection: "shopping_items", Patches: []PatchOperation{{Op: "add", Path: "/deleted_at", Value: time.Now()}}, } eventStore.ProcessEvent(&event3) 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) assert.NoError(t, err) items, ok := response["items"].([]interface{}) assert.True(t, ok) assert.Len(t, items, 1) // Only non-deleted item } func TestAPI_GetStateEndpoint(t *testing.T) { eventStore := NewMockSimpleEventStore() router := setupTestRouter(eventStore) // Test with no events 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) assert.NoError(t, err) assert.Equal(t, float64(0), response["seq"]) assert.Equal(t, "", response["hash"]) // Create an event event := Event{ ItemID: "item1", Collection: "shopping_items", Patches: []PatchOperation{{Op: "add", Path: "/content", Value: "Item 1"}}, } processedEvent, _ := eventStore.ProcessEvent(&event) // Test with events 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) assert.NoError(t, err) assert.Equal(t, float64(processedEvent.Seq), response["seq"]) assert.Equal(t, processedEvent.Hash, response["hash"]) } func TestAPI_PathValues(t *testing.T) { eventStore := NewMockSimpleEventStore() router := setupTestRouter(eventStore) // Test with empty collection name (router will handle as empty path value) patches := []PatchOperation{{Op: "add", Path: "/content", Value: "test"}} body, _ := json.Marshal(patches) req := httptest.NewRequest("PATCH", "/api/collections/empty/items/item1", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) // This should work fine - we'll test empty values in the handler assert.Equal(t, http.StatusOK, w.Code) // Test empty item ID by using an empty string req = httptest.NewRequest("PATCH", "/api/collections/test/items/", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w = httptest.NewRecorder() router.ServeHTTP(w, req) // This will likely result in 404 due to route not matching assert.True(t, w.Code == http.StatusNotFound || w.Code == http.StatusBadRequest) }