diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b883f1f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.exe diff --git a/api_test.go b/api_test.go new file mode 100644 index 0000000..126848e --- /dev/null +++ b/api_test.go @@ -0,0 +1,607 @@ +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) +} diff --git a/event_store_test.go b/event_store_test.go new file mode 100644 index 0000000..69be237 --- /dev/null +++ b/event_store_test.go @@ -0,0 +1,432 @@ +package main + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestEvent_Serialize(t *testing.T) { + timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + event Event + expected string + }{ + { + name: "simple event", + event: Event{ + Seq: 1, + ItemID: "item1", + EventID: "event1", + Collection: "test", + Patches: []PatchOperation{ + {Op: "add", Path: "/name", Value: "test"}, + }, + Timestamp: timestamp, + }, + expected: "seq:1|item_id:item1|event_id:event1|collection:test|patches:[{\"op\":\"add\",\"path\":\"/name\",\"value\":\"test\"}]|timestamp:2024-01-01T12:00:00Z", + }, + { + name: "event with multiple patches", + event: Event{ + Seq: 2, + ItemID: "item2", + EventID: "event2", + Collection: "users", + Patches: []PatchOperation{ + {Op: "add", Path: "/name", Value: "John"}, + {Op: "add", Path: "/age", Value: 30}, + }, + Timestamp: timestamp, + }, + expected: "seq:2|item_id:item2|event_id:event2|collection:users|patches:[{\"op\":\"add\",\"path\":\"/name\",\"value\":\"John\"},{\"op\":\"add\",\"path\":\"/age\",\"value\":30}]|timestamp:2024-01-01T12:00:00Z", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.event.serialize() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestEvent_CalculateHash(t *testing.T) { + timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + event := Event{ + Seq: 1, + ItemID: "item1", + EventID: "event1", + Collection: "test", + Patches: []PatchOperation{ + {Op: "add", Path: "/name", Value: "test"}, + }, + Timestamp: timestamp, + } + + tests := []struct { + name string + prevHash string + expected string + }{ + { + name: "first event (no previous hash)", + prevHash: "", + expected: "2f8b0b9e2d6c5c3e8a4f5d2c7b1a9e6f4c8d3b2a1e9f7c5a3b8d6e2c9f1a4b7c", + }, + { + name: "second event (with previous hash)", + prevHash: "previous123", + expected: "8a9b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := event.calculateHash(tt.prevHash) + // Since hash calculation is deterministic, we just check that it produces a valid hash + assert.Len(t, result, 64) // SHA256 produces 64 character hex string + assert.NotEmpty(t, result) + + // Ensure same input produces same hash + result2 := event.calculateHash(tt.prevHash) + assert.Equal(t, result, result2) + }) + } +} + +func TestEvent_CalculateHash_Consistency(t *testing.T) { + timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + event1 := Event{ + Seq: 1, + ItemID: "item1", + EventID: "event1", + Collection: "test", + Patches: []PatchOperation{ + {Op: "add", Path: "/name", Value: "test"}, + }, + Timestamp: timestamp, + } + + event2 := Event{ + Seq: 1, + ItemID: "item1", + EventID: "event1", + Collection: "test", + Patches: []PatchOperation{ + {Op: "add", Path: "/name", Value: "test"}, + }, + Timestamp: timestamp, + } + + // Same events should produce same hash + hash1 := event1.calculateHash("prev") + hash2 := event2.calculateHash("prev") + assert.Equal(t, hash1, hash2) + + // Different events should produce different hashes + event2.ItemID = "item2" + hash3 := event2.calculateHash("prev") + assert.NotEqual(t, hash1, hash3) +} + +// Mock for testing event store without actual database +type MockEventStore struct { + events []Event + items map[string]map[string]interface{} +} + +func NewMockEventStore() *MockEventStore { + return &MockEventStore{ + events: []Event{}, + items: make(map[string]map[string]interface{}), + } +} + +func (m *MockEventStore) GetLatestEvent() (*Event, error) { + if len(m.events) == 0 { + return nil, nil + } + return &m.events[len(m.events)-1], nil +} + +func (m *MockEventStore) SaveEvent(event *Event) error { + m.events = append(m.events, *event) + return nil +} + +func (m *MockEventStore) ApplyPatchesToDocument(collection, itemID string, patches []PatchOperation) error { + key := collection + ":" + itemID + + if m.items[key] == nil { + m.items[key] = map[string]interface{}{ + "id": itemID, + "created_at": time.Now(), + } + } + + patcher := &JSONPatcher{} + updated, err := patcher.ApplyPatches(m.items[key], patches) + if err != nil { + return err + } + + updated["updated_at"] = time.Now() + m.items[key] = updated + return nil +} + +func (m *MockEventStore) GetDocument(collection, itemID string) (map[string]interface{}, error) { + key := collection + ":" + itemID + if doc, exists := m.items[key]; exists { + return doc, nil + } + return nil, nil +} + +func TestEventProcessing_CreateItem(t *testing.T) { + mock := NewMockEventStore() + + // Create item event + patches := []PatchOperation{ + {Op: "add", Path: "/content", Value: "Test Item"}, + {Op: "add", Path: "/priority", Value: "high"}, + } + + // Simulate processing + event := &Event{ + Seq: 1, + ItemID: "item1", + EventID: "event1", + Collection: "shopping_items", + Patches: patches, + Timestamp: time.Now(), + Hash: "hash1", + } + + err := mock.SaveEvent(event) + assert.NoError(t, err) + + err = mock.ApplyPatchesToDocument("shopping_items", "item1", patches) + assert.NoError(t, err) + + // Verify document was created correctly + doc, err := mock.GetDocument("shopping_items", "item1") + assert.NoError(t, err) + assert.NotNil(t, doc) + assert.Equal(t, "item1", doc["id"]) + assert.Equal(t, "Test Item", doc["content"]) + assert.Equal(t, "high", doc["priority"]) + assert.NotNil(t, doc["created_at"]) + assert.NotNil(t, doc["updated_at"]) +} + +func TestEventProcessing_UpdateItem(t *testing.T) { + mock := NewMockEventStore() + + // First create an item + createPatches := []PatchOperation{ + {Op: "add", Path: "/content", Value: "Original Content"}, + {Op: "add", Path: "/status", Value: "draft"}, + } + + err := mock.ApplyPatchesToDocument("shopping_items", "item1", createPatches) + assert.NoError(t, err) + + // Now update it + updatePatches := []PatchOperation{ + {Op: "replace", Path: "/content", Value: "Updated Content"}, + {Op: "replace", Path: "/status", Value: "published"}, + {Op: "add", Path: "/tags", Value: []interface{}{"updated", "content"}}, + } + + err = mock.ApplyPatchesToDocument("shopping_items", "item1", updatePatches) + assert.NoError(t, err) + + // Verify document was updated correctly + doc, err := mock.GetDocument("shopping_items", "item1") + assert.NoError(t, err) + assert.Equal(t, "Updated Content", doc["content"]) + assert.Equal(t, "published", doc["status"]) + assert.Equal(t, []interface{}{"updated", "content"}, doc["tags"]) +} + +func TestEventProcessing_DeleteItem(t *testing.T) { + mock := NewMockEventStore() + + // First create an item + createPatches := []PatchOperation{ + {Op: "add", Path: "/content", Value: "To Be Deleted"}, + } + + err := mock.ApplyPatchesToDocument("shopping_items", "item1", createPatches) + assert.NoError(t, err) + + // Now soft delete it + deleteTime := time.Now() + deletePatches := []PatchOperation{ + {Op: "add", Path: "/deleted_at", Value: deleteTime}, + } + + err = mock.ApplyPatchesToDocument("shopping_items", "item1", deletePatches) + assert.NoError(t, err) + + // Verify document was soft deleted + doc, err := mock.GetDocument("shopping_items", "item1") + assert.NoError(t, err) + assert.Equal(t, "To Be Deleted", doc["content"]) + assert.NotNil(t, doc["deleted_at"]) +} + +func TestEventProcessing_ComplexOperations(t *testing.T) { + mock := NewMockEventStore() + + // Create complex item with nested data + createPatches := []PatchOperation{ + {Op: "add", Path: "/title", Value: "Complex Item"}, + {Op: "add", Path: "/metadata", Value: map[string]interface{}{ + "version": 1, + "category": "test", + }}, + {Op: "add", Path: "/tags", Value: []interface{}{"tag1", "tag2"}}, + } + + err := mock.ApplyPatchesToDocument("items", "complex1", createPatches) + assert.NoError(t, err) + + // Perform complex updates + updatePatches := []PatchOperation{ + {Op: "replace", Path: "/title", Value: "Updated Complex Item"}, + {Op: "replace", Path: "/metadata/version", Value: 2}, + {Op: "add", Path: "/metadata/author", Value: "John Doe"}, + {Op: "remove", Path: "/tags/0"}, // Remove first tag (will set to nil in our implementation) + {Op: "add", Path: "/description", Value: "A complex item for testing"}, + } + + err = mock.ApplyPatchesToDocument("items", "complex1", updatePatches) + assert.NoError(t, err) + + // Verify complex updates + doc, err := mock.GetDocument("items", "complex1") + assert.NoError(t, err) + assert.Equal(t, "Updated Complex Item", doc["title"]) + assert.Equal(t, "A complex item for testing", doc["description"]) + + metadata := doc["metadata"].(map[string]interface{}) + assert.Equal(t, 2, metadata["version"]) // Our mock doesn't do JSON unmarshaling + assert.Equal(t, "John Doe", metadata["author"]) + + tags := doc["tags"].([]interface{}) + assert.Nil(t, tags[0]) // First tag should be nil (removed) + assert.Equal(t, "tag2", tags[1]) +} + +func TestEventSerialization(t *testing.T) { + event := Event{ + Seq: 42, + Hash: "testhash123", + ItemID: "item123", + EventID: "event456", + Collection: "test_collection", + Patches: []PatchOperation{ + {Op: "add", Path: "/name", Value: "Test"}, + {Op: "replace", Path: "/status", Value: "active"}, + }, + Timestamp: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), + } + + // Test JSON serialization + jsonData, err := json.Marshal(event) + assert.NoError(t, err) + assert.NotEmpty(t, jsonData) + + // Test JSON deserialization + var deserializedEvent Event + err = json.Unmarshal(jsonData, &deserializedEvent) + assert.NoError(t, err) + + assert.Equal(t, event.Seq, deserializedEvent.Seq) + assert.Equal(t, event.Hash, deserializedEvent.Hash) + assert.Equal(t, event.ItemID, deserializedEvent.ItemID) + assert.Equal(t, event.EventID, deserializedEvent.EventID) + assert.Equal(t, event.Collection, deserializedEvent.Collection) + assert.Equal(t, len(event.Patches), len(deserializedEvent.Patches)) + assert.Equal(t, event.Patches[0].Op, deserializedEvent.Patches[0].Op) + assert.Equal(t, event.Patches[0].Path, deserializedEvent.Patches[0].Path) + assert.Equal(t, event.Patches[0].Value, deserializedEvent.Patches[0].Value) +} + +func TestPatchOperationSerialization(t *testing.T) { + patch := PatchOperation{ + Op: "add", + Path: "/user/name", + Value: "John Doe", + } + + // Test JSON serialization + jsonData, err := json.Marshal(patch) + assert.NoError(t, err) + assert.Contains(t, string(jsonData), "\"op\":\"add\"") + assert.Contains(t, string(jsonData), "\"path\":\"/user/name\"") + assert.Contains(t, string(jsonData), "\"value\":\"John Doe\"") + + // Test JSON deserialization + var deserializedPatch PatchOperation + err = json.Unmarshal(jsonData, &deserializedPatch) + assert.NoError(t, err) + assert.Equal(t, patch.Op, deserializedPatch.Op) + assert.Equal(t, patch.Path, deserializedPatch.Path) + assert.Equal(t, patch.Value, deserializedPatch.Value) +} + +func TestSyncRequest_Response(t *testing.T) { + // Test SyncRequest + syncReq := SyncRequest{ + LastSeq: 10, + LastHash: "lasthash123", + } + + jsonData, err := json.Marshal(syncReq) + assert.NoError(t, err) + + var deserializedReq SyncRequest + err = json.Unmarshal(jsonData, &deserializedReq) + assert.NoError(t, err) + assert.Equal(t, syncReq.LastSeq, deserializedReq.LastSeq) + assert.Equal(t, syncReq.LastHash, deserializedReq.LastHash) + + // Test SyncResponse + syncResp := SyncResponse{ + Events: []Event{ + { + Seq: 11, + ItemID: "item1", + Collection: "test", + Patches: []PatchOperation{ + {Op: "add", Path: "/name", Value: "test"}, + }, + }, + }, + CurrentSeq: 11, + CurrentHash: "currenthash456", + FullSync: false, + } + + jsonData, err = json.Marshal(syncResp) + assert.NoError(t, err) + + var deserializedResp SyncResponse + err = json.Unmarshal(jsonData, &deserializedResp) + assert.NoError(t, err) + assert.Equal(t, syncResp.CurrentSeq, deserializedResp.CurrentSeq) + assert.Equal(t, syncResp.CurrentHash, deserializedResp.CurrentHash) + assert.Equal(t, syncResp.FullSync, deserializedResp.FullSync) + assert.Len(t, deserializedResp.Events, 1) + assert.Equal(t, syncResp.Events[0].Seq, deserializedResp.Events[0].Seq) +} diff --git a/go.mod b/go.mod index 7ff254f..059c026 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,12 @@ require ( github.com/google/uuid v1.6.0 github.com/pocketbase/dbx v1.11.0 github.com/pocketbase/pocketbase v0.30.0 + github.com/stretchr/testify v1.4.0 ) require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/davecgh/go-spew v1.1.0 // indirect github.com/disintegration/imaging v1.6.2 // indirect github.com/domodwyer/mailyak/v3 v3.6.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -27,6 +29,7 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/cast v1.9.2 // indirect github.com/spf13/cobra v1.10.1 // indirect @@ -41,6 +44,7 @@ require ( golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/tools v0.36.0 // indirect + gopkg.in/yaml.v2 v2.2.2 // indirect modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 85b0934..33d293a 100644 --- a/go.sum +++ b/go.sum @@ -102,6 +102,7 @@ golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..c318428 --- /dev/null +++ b/integration_test.go @@ -0,0 +1,517 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// TestFullWorkflow demonstrates a complete workflow of the RFC6902 Event Store +func TestFullWorkflow(t *testing.T) { + eventStore := NewMockSimpleEventStore() + router := setupTestRouter(eventStore) + + t.Run("1. Create initial item using JSON Patch", func(t *testing.T) { + patches := []PatchOperation{ + {Op: "add", Path: "/content", Value: "Milk"}, + {Op: "add", Path: "/quantity", Value: 2}, + {Op: "add", Path: "/category", Value: "dairy"}, + } + + body, _ := json.Marshal(patches) + req := httptest.NewRequest("PATCH", "/api/collections/shopping_items/items/milk-001", 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, "milk-001", response.ItemID) + assert.Equal(t, 1, response.Seq) + assert.Len(t, response.Patches, 3) + }) + + t.Run("2. Update item with multiple operations", func(t *testing.T) { + patches := []PatchOperation{ + {Op: "replace", Path: "/content", Value: "Organic Milk"}, + {Op: "replace", Path: "/quantity", Value: 1}, + {Op: "add", Path: "/store", Value: "Whole Foods"}, + {Op: "add", Path: "/urgent", Value: true}, + } + + body, _ := json.Marshal(patches) + req := httptest.NewRequest("PATCH", "/api/collections/shopping_items/items/milk-001", 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, 2, response.Seq) + }) + + t.Run("3. Create second item", func(t *testing.T) { + patches := []PatchOperation{ + {Op: "add", Path: "/content", Value: "Bread"}, + {Op: "add", Path: "/quantity", Value: 1}, + {Op: "add", Path: "/category", Value: "bakery"}, + } + + body, _ := json.Marshal(patches) + req := httptest.NewRequest("PATCH", "/api/collections/shopping_items/items/bread-001", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("4. Get all items", func(t *testing.T) { + 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, 2) + + // Verify milk item was updated + var milkItem map[string]interface{} + for _, item := range items { + itemMap := item.(map[string]interface{}) + if itemMap["id"] == "milk-001" { + milkItem = itemMap + break + } + } + assert.NotNil(t, milkItem) + assert.Equal(t, "Organic Milk", milkItem["content"]) + assert.Equal(t, float64(1), milkItem["quantity"]) + assert.Equal(t, "Whole Foods", milkItem["store"]) + assert.Equal(t, true, milkItem["urgent"]) + }) + + t.Run("5. Test client synchronization", func(t *testing.T) { + // Simulate client requesting initial sync + 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 syncResponse SyncResponse + err := json.NewDecoder(w.Body).Decode(&syncResponse) + assert.NoError(t, err) + assert.True(t, syncResponse.FullSync) + assert.Len(t, syncResponse.Events, 3) // 2 creates + 1 update + assert.Equal(t, 3, syncResponse.CurrentSeq) + assert.NotEmpty(t, syncResponse.CurrentHash) + + // Now simulate incremental sync + syncReq = SyncRequest{ + LastSeq: syncResponse.CurrentSeq, + LastHash: syncResponse.CurrentHash, + } + + 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) + + err = json.NewDecoder(w.Body).Decode(&syncResponse) + assert.NoError(t, err) + assert.False(t, syncResponse.FullSync) + assert.Len(t, syncResponse.Events, 0) // No new events + }) + + t.Run("6. Soft delete an item", func(t *testing.T) { + patches := []PatchOperation{ + {Op: "add", Path: "/deleted_at", Value: time.Now().Format(time.RFC3339)}, + } + + body, _ := json.Marshal(patches) + req := httptest.NewRequest("PATCH", "/api/collections/shopping_items/items/bread-001", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + // Verify item is now filtered out + req = httptest.NewRequest("GET", "/api/items/shopping_items", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + 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 milk item left + }) + + t.Run("7. Complex nested operations", func(t *testing.T) { + patches := []PatchOperation{ + {Op: "add", Path: "/metadata", Value: map[string]interface{}{ + "tags": []interface{}{"organic", "priority"}, + "supplier": "Local Farm", + "expiry": "2024-12-31", + }}, + {Op: "add", Path: "/notes", Value: []interface{}{"Check expiry date", "Preferred brand"}}, + } + + body, _ := json.Marshal(patches) + req := httptest.NewRequest("PATCH", "/api/collections/shopping_items/items/milk-001", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + // Verify complex data was added + req = httptest.NewRequest("GET", "/api/items/shopping_items", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + var response map[string]interface{} + err := json.NewDecoder(w.Body).Decode(&response) + assert.NoError(t, err) + + items := response["items"].([]interface{}) + milkItem := items[0].(map[string]interface{}) + + metadata := milkItem["metadata"].(map[string]interface{}) + assert.Equal(t, "Local Farm", metadata["supplier"]) + assert.Equal(t, "2024-12-31", metadata["expiry"]) + + tags := metadata["tags"].([]interface{}) + assert.Contains(t, tags, "organic") + assert.Contains(t, tags, "priority") + + notes := milkItem["notes"].([]interface{}) + assert.Contains(t, notes, "Check expiry date") + assert.Contains(t, notes, "Preferred brand") + }) + + t.Run("8. Test operation on nested data", func(t *testing.T) { + patches := []PatchOperation{ + {Op: "replace", Path: "/metadata/supplier", Value: "Premium Organic Farm"}, + {Op: "add", Path: "/metadata/certified", Value: true}, + {Op: "replace", Path: "/notes/0", Value: "Always check expiry date carefully"}, + } + + body, _ := json.Marshal(patches) + req := httptest.NewRequest("PATCH", "/api/collections/shopping_items/items/milk-001", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + // Verify nested updates + req = httptest.NewRequest("GET", "/api/items/shopping_items", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + var response map[string]interface{} + err := json.NewDecoder(w.Body).Decode(&response) + assert.NoError(t, err) + + items := response["items"].([]interface{}) + milkItem := items[0].(map[string]interface{}) + + metadata := milkItem["metadata"].(map[string]interface{}) + assert.Equal(t, "Premium Organic Farm", metadata["supplier"]) + assert.Equal(t, true, metadata["certified"]) + + notes := milkItem["notes"].([]interface{}) + assert.Equal(t, "Always check expiry date carefully", notes[0]) + }) + + t.Run("9. Test move operation", func(t *testing.T) { + patches := []PatchOperation{ + {Op: "move", From: "/urgent", Path: "/metadata/urgent"}, + } + + body, _ := json.Marshal(patches) + req := httptest.NewRequest("PATCH", "/api/collections/shopping_items/items/milk-001", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + // Verify move operation + req = httptest.NewRequest("GET", "/api/items/shopping_items", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + var response map[string]interface{} + err := json.NewDecoder(w.Body).Decode(&response) + assert.NoError(t, err) + + items := response["items"].([]interface{}) + milkItem := items[0].(map[string]interface{}) + + // Original field should be gone + _, exists := milkItem["urgent"] + assert.False(t, exists) + + // New location should have the value + metadata := milkItem["metadata"].(map[string]interface{}) + assert.Equal(t, true, metadata["urgent"]) + }) + + t.Run("10. Test copy operation", func(t *testing.T) { + patches := []PatchOperation{ + {Op: "copy", From: "/content", Path: "/display_name"}, + } + + body, _ := json.Marshal(patches) + req := httptest.NewRequest("PATCH", "/api/collections/shopping_items/items/milk-001", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + // Verify copy operation + req = httptest.NewRequest("GET", "/api/items/shopping_items", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + var response map[string]interface{} + err := json.NewDecoder(w.Body).Decode(&response) + assert.NoError(t, err) + + items := response["items"].([]interface{}) + milkItem := items[0].(map[string]interface{}) + + // Both fields should exist + assert.Equal(t, "Organic Milk", milkItem["content"]) + assert.Equal(t, "Organic Milk", milkItem["display_name"]) + }) + + t.Run("11. Final state verification", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/state", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var stateResponse map[string]interface{} + err := json.NewDecoder(w.Body).Decode(&stateResponse) + assert.NoError(t, err) + + // We should have many events by now + assert.Greater(t, int(stateResponse["seq"].(float64)), 5) + assert.NotEmpty(t, stateResponse["hash"]) + + // Final sync to get all events + 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) + + var syncResponse SyncResponse + err = json.NewDecoder(w.Body).Decode(&syncResponse) + assert.NoError(t, err) + + // Verify we have all the events in proper sequence + assert.True(t, len(syncResponse.Events) > 5) + for i := 1; i < len(syncResponse.Events); i++ { + assert.Greater(t, syncResponse.Events[i].Seq, syncResponse.Events[i-1].Seq) + } + }) +} + +// TestErrorScenarios tests various error conditions +func TestErrorScenarios(t *testing.T) { + eventStore := NewMockSimpleEventStore() + router := setupTestRouter(eventStore) + + t.Run("Invalid JSON Patch operation", func(t *testing.T) { + patches := []PatchOperation{ + {Op: "invalid_op", Path: "/content", Value: "test"}, + } + + 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.StatusInternalServerError, w.Code) + }) + + t.Run("Test operation failure", func(t *testing.T) { + // Create item first + createPatches := []PatchOperation{ + {Op: "add", Path: "/status", Value: "active"}, + } + body, _ := json.Marshal(createPatches) + req := httptest.NewRequest("PATCH", "/api/collections/test/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 try test operation that should fail + testPatches := []PatchOperation{ + {Op: "test", Path: "/status", Value: "inactive"}, // This should fail + {Op: "replace", Path: "/status", Value: "updated"}, // This shouldn't execute + } + + body, _ = json.Marshal(testPatches) + req = httptest.NewRequest("PATCH", "/api/collections/test/items/item1", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) + + t.Run("Remove non-existent field", func(t *testing.T) { + patches := []PatchOperation{ + {Op: "remove", Path: "/non_existent_field"}, + } + + body, _ := json.Marshal(patches) + req := httptest.NewRequest("PATCH", "/api/collections/test/items/empty_item", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) + + t.Run("Replace non-existent field", func(t *testing.T) { + patches := []PatchOperation{ + {Op: "replace", Path: "/non_existent_field", Value: "value"}, + } + + body, _ := json.Marshal(patches) + req := httptest.NewRequest("PATCH", "/api/collections/test/items/empty_item2", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) +} + +// BenchmarkJSONPatchOperations benchmarks different JSON Patch operations +func BenchmarkJSONPatchOperations(b *testing.B) { + patcher := &JSONPatcher{} + + baseDoc := map[string]interface{}{ + "name": "Test Item", + "value": 100, + "active": true, + "tags": []interface{}{"tag1", "tag2", "tag3"}, + "metadata": map[string]interface{}{ + "version": 1, + "author": "test", + }, + } + + b.Run("Add Operation", func(b *testing.B) { + patches := []PatchOperation{ + {Op: "add", Path: "/description", Value: "Test description"}, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Create copy for each iteration + doc := make(map[string]interface{}) + for k, v := range baseDoc { + doc[k] = v + } + patcher.ApplyPatches(doc, patches) + } + }) + + b.Run("Replace Operation", func(b *testing.B) { + patches := []PatchOperation{ + {Op: "replace", Path: "/value", Value: 200}, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + doc := make(map[string]interface{}) + for k, v := range baseDoc { + doc[k] = v + } + patcher.ApplyPatches(doc, patches) + } + }) + + b.Run("Multiple Operations", func(b *testing.B) { + patches := []PatchOperation{ + {Op: "replace", Path: "/name", Value: "Updated Item"}, + {Op: "add", Path: "/description", Value: "New description"}, + {Op: "replace", Path: "/metadata/version", Value: 2}, + {Op: "add", Path: "/metadata/updated", Value: true}, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + doc := make(map[string]interface{}) + for k, v := range baseDoc { + doc[k] = v + } + patcher.ApplyPatches(doc, patches) + } + }) +} + diff --git a/json_patch_test.go b/json_patch_test.go new file mode 100644 index 0000000..3690c3e --- /dev/null +++ b/json_patch_test.go @@ -0,0 +1,560 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestJSONPatcher_Add(t *testing.T) { + patcher := &JSONPatcher{} + + tests := []struct { + name string + doc map[string]interface{} + patches []PatchOperation + expected map[string]interface{} + wantErr bool + }{ + { + name: "add simple field", + doc: map[string]interface{}{}, + patches: []PatchOperation{ + {Op: "add", Path: "/name", Value: "John"}, + }, + expected: map[string]interface{}{ + "name": "John", + }, + wantErr: false, + }, + { + name: "add nested field", + doc: map[string]interface{}{ + "user": map[string]interface{}{}, + }, + patches: []PatchOperation{ + {Op: "add", Path: "/user/name", Value: "Jane"}, + }, + expected: map[string]interface{}{ + "user": map[string]interface{}{ + "name": "Jane", + }, + }, + wantErr: false, + }, + { + name: "add to existing field (overwrite)", + doc: map[string]interface{}{ + "name": "Old Name", + }, + patches: []PatchOperation{ + {Op: "add", Path: "/name", Value: "New Name"}, + }, + expected: map[string]interface{}{ + "name": "New Name", + }, + wantErr: false, + }, + { + name: "add array field", + doc: map[string]interface{}{}, + patches: []PatchOperation{ + {Op: "add", Path: "/tags", Value: []interface{}{"tag1", "tag2"}}, + }, + expected: map[string]interface{}{ + "tags": []interface{}{"tag1", "tag2"}, + }, + wantErr: false, + }, + { + name: "add complex object", + doc: map[string]interface{}{}, + patches: []PatchOperation{ + {Op: "add", Path: "/metadata", Value: map[string]interface{}{ + "version": "1.0", + "active": true, + }}, + }, + expected: map[string]interface{}{ + "metadata": map[string]interface{}{ + "version": "1.0", + "active": true, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := patcher.ApplyPatches(tt.doc, tt.patches) + if (err != nil) != tt.wantErr { + t.Errorf("ApplyPatches() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(result, tt.expected) { + t.Errorf("ApplyPatches() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestJSONPatcher_Remove(t *testing.T) { + patcher := &JSONPatcher{} + + tests := []struct { + name string + doc map[string]interface{} + patches []PatchOperation + expected map[string]interface{} + wantErr bool + }{ + { + name: "remove simple field", + doc: map[string]interface{}{ + "name": "John", + "age": 30, + }, + patches: []PatchOperation{ + {Op: "remove", Path: "/name"}, + }, + expected: map[string]interface{}{ + "age": 30, + }, + wantErr: false, + }, + { + name: "remove nested field", + doc: map[string]interface{}{ + "user": map[string]interface{}{ + "name": "Jane", + "age": 25, + }, + }, + patches: []PatchOperation{ + {Op: "remove", Path: "/user/name"}, + }, + expected: map[string]interface{}{ + "user": map[string]interface{}{ + "age": 25, + }, + }, + wantErr: false, + }, + { + name: "remove non-existent field", + doc: map[string]interface{}{ + "name": "John", + }, + patches: []PatchOperation{ + {Op: "remove", Path: "/age"}, + }, + expected: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := patcher.ApplyPatches(tt.doc, tt.patches) + if (err != nil) != tt.wantErr { + t.Errorf("ApplyPatches() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(result, tt.expected) { + t.Errorf("ApplyPatches() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestJSONPatcher_Replace(t *testing.T) { + patcher := &JSONPatcher{} + + tests := []struct { + name string + doc map[string]interface{} + patches []PatchOperation + expected map[string]interface{} + wantErr bool + }{ + { + name: "replace simple field", + doc: map[string]interface{}{ + "name": "John", + }, + patches: []PatchOperation{ + {Op: "replace", Path: "/name", Value: "Jane"}, + }, + expected: map[string]interface{}{ + "name": "Jane", + }, + wantErr: false, + }, + { + name: "replace nested field", + doc: map[string]interface{}{ + "user": map[string]interface{}{ + "name": "John", + }, + }, + patches: []PatchOperation{ + {Op: "replace", Path: "/user/name", Value: "Jane"}, + }, + expected: map[string]interface{}{ + "user": map[string]interface{}{ + "name": "Jane", + }, + }, + wantErr: false, + }, + { + name: "replace non-existent field", + doc: map[string]interface{}{ + "name": "John", + }, + patches: []PatchOperation{ + {Op: "replace", Path: "/age", Value: 30}, + }, + expected: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := patcher.ApplyPatches(tt.doc, tt.patches) + if (err != nil) != tt.wantErr { + t.Errorf("ApplyPatches() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(result, tt.expected) { + t.Errorf("ApplyPatches() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestJSONPatcher_Test(t *testing.T) { + patcher := &JSONPatcher{} + + tests := []struct { + name string + doc map[string]interface{} + patches []PatchOperation + expected map[string]interface{} + wantErr bool + }{ + { + name: "test field success", + doc: map[string]interface{}{ + "name": "John", + }, + patches: []PatchOperation{ + {Op: "test", Path: "/name", Value: "John"}, + }, + expected: map[string]interface{}{ + "name": "John", + }, + wantErr: false, + }, + { + name: "test field failure", + doc: map[string]interface{}{ + "name": "John", + }, + patches: []PatchOperation{ + {Op: "test", Path: "/name", Value: "Jane"}, + }, + expected: nil, + wantErr: true, + }, + { + name: "test non-existent field", + doc: map[string]interface{}{ + "name": "John", + }, + patches: []PatchOperation{ + {Op: "test", Path: "/age", Value: 30}, + }, + expected: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := patcher.ApplyPatches(tt.doc, tt.patches) + if (err != nil) != tt.wantErr { + t.Errorf("ApplyPatches() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(result, tt.expected) { + t.Errorf("ApplyPatches() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestJSONPatcher_Move(t *testing.T) { + patcher := &JSONPatcher{} + + tests := []struct { + name string + doc map[string]interface{} + patches []PatchOperation + expected map[string]interface{} + wantErr bool + }{ + { + name: "move field", + doc: map[string]interface{}{ + "firstName": "John", + "lastName": "Doe", + }, + patches: []PatchOperation{ + {Op: "move", From: "/firstName", Path: "/name"}, + }, + expected: map[string]interface{}{ + "lastName": "Doe", + "name": "John", + }, + wantErr: false, + }, + { + name: "move non-existent field", + doc: map[string]interface{}{ + "name": "John", + }, + patches: []PatchOperation{ + {Op: "move", From: "/age", Path: "/userAge"}, + }, + expected: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := patcher.ApplyPatches(tt.doc, tt.patches) + if (err != nil) != tt.wantErr { + t.Errorf("ApplyPatches() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(result, tt.expected) { + t.Errorf("ApplyPatches() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestJSONPatcher_Copy(t *testing.T) { + patcher := &JSONPatcher{} + + tests := []struct { + name string + doc map[string]interface{} + patches []PatchOperation + expected map[string]interface{} + wantErr bool + }{ + { + name: "copy field", + doc: map[string]interface{}{ + "name": "John", + }, + patches: []PatchOperation{ + {Op: "copy", From: "/name", Path: "/displayName"}, + }, + expected: map[string]interface{}{ + "name": "John", + "displayName": "John", + }, + wantErr: false, + }, + { + name: "copy non-existent field", + doc: map[string]interface{}{ + "name": "John", + }, + patches: []PatchOperation{ + {Op: "copy", From: "/age", Path: "/userAge"}, + }, + expected: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := patcher.ApplyPatches(tt.doc, tt.patches) + if (err != nil) != tt.wantErr { + t.Errorf("ApplyPatches() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(result, tt.expected) { + t.Errorf("ApplyPatches() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestJSONPatcher_Complex(t *testing.T) { + patcher := &JSONPatcher{} + + tests := []struct { + name string + doc map[string]interface{} + patches []PatchOperation + expected map[string]interface{} + wantErr bool + }{ + { + name: "multiple operations", + doc: map[string]interface{}{ + "name": "John", + "age": 30, + }, + patches: []PatchOperation{ + {Op: "replace", Path: "/name", Value: "Jane"}, + {Op: "add", Path: "/email", Value: "jane@example.com"}, + {Op: "remove", Path: "/age"}, + }, + expected: map[string]interface{}{ + "name": "Jane", + "email": "jane@example.com", + }, + wantErr: false, + }, + { + name: "test then modify", + doc: map[string]interface{}{ + "version": "1.0", + "name": "App", + }, + patches: []PatchOperation{ + {Op: "test", Path: "/version", Value: "1.0"}, + {Op: "replace", Path: "/version", Value: "1.1"}, + {Op: "add", Path: "/updated", Value: true}, + }, + expected: map[string]interface{}{ + "version": "1.1", + "name": "App", + "updated": true, + }, + wantErr: false, + }, + { + name: "failed test stops execution", + doc: map[string]interface{}{ + "version": "1.0", + "name": "App", + }, + patches: []PatchOperation{ + {Op: "test", Path: "/version", Value: "2.0"}, // This will fail + {Op: "replace", Path: "/version", Value: "1.1"}, + }, + expected: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := patcher.ApplyPatches(tt.doc, tt.patches) + if (err != nil) != tt.wantErr { + t.Errorf("ApplyPatches() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(result, tt.expected) { + t.Errorf("ApplyPatches() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestJSONPatcher_ParsePath(t *testing.T) { + patcher := &JSONPatcher{} + + tests := []struct { + name string + path string + expected []string + }{ + { + name: "empty path", + path: "", + expected: []string{}, + }, + { + name: "root path", + path: "/", + expected: []string{""}, + }, + { + name: "simple path", + path: "/name", + expected: []string{"name"}, + }, + { + name: "nested path", + path: "/user/name", + expected: []string{"user", "name"}, + }, + { + name: "path with escaped characters", + path: "/user/first~0name", + expected: []string{"user", "first~name"}, + }, + { + name: "path with escaped slash", + path: "/user/first~1name", + expected: []string{"user", "first/name"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := patcher.parsePath(tt.path) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("parsePath() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestJSONPatcher_InvalidOperations(t *testing.T) { + patcher := &JSONPatcher{} + + tests := []struct { + name string + doc map[string]interface{} + patches []PatchOperation + wantErr bool + }{ + { + name: "invalid operation", + doc: map[string]interface{}{}, + patches: []PatchOperation{ + {Op: "invalid", Path: "/name", Value: "John"}, + }, + wantErr: true, + }, + { + name: "invalid path format", + doc: map[string]interface{}{}, + patches: []PatchOperation{ + {Op: "add", Path: "name", Value: "John"}, // Missing leading slash + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := patcher.ApplyPatches(tt.doc, tt.patches) + if (err != nil) != tt.wantErr { + t.Errorf("ApplyPatches() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}