518 lines
15 KiB
Go
518 lines
15 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
|