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